mirror of
https://github.com/streetwriters/notesnook.git
synced 2026-09-03 12:41:45 +02:00
Merge remote-tracking branch 'origin/master' into responsive
This commit is contained in:
21
apps/web/.github/workflows/build.yml
vendored
Normal file
21
apps/web/.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Build
|
||||
|
||||
on: [pull_request, push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_DEPLOY_KEY: ${{ secrets.GH_DEPLOY_KEY }}
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
- run: npm install
|
||||
- run: npm build
|
||||
33
apps/web/.github/workflows/deploy.yml
vendored
Normal file
33
apps/web/.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Build and Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_DEPLOY_KEY: ${{ secrets.GH_DEPLOY_KEY }}
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Use Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
- run: npm install
|
||||
|
||||
- name: Build and Deploy 🚀
|
||||
uses: amondnet/now-deployment@v2
|
||||
with:
|
||||
zeit-token: ${{ secrets.ZEIT_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
now-args: "--prod"
|
||||
now-org-id: "Wst8mhjfr4LzbjOe956M2Z4t"
|
||||
now-project-id: "QmStgLZ26V8aowiAAdyYaFVjRDWP9T8GEUoR9iJFSSnpWJ"
|
||||
35
apps/web/.github/workflows/eslint.yml
vendored
Normal file
35
apps/web/.github/workflows/eslint.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# This is a basic workflow to help you get started with Actions
|
||||
|
||||
name: ESLint
|
||||
|
||||
# Controls when the action will run. Triggers the workflow on push or pull request
|
||||
# events but only for the master branch
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
|
||||
jobs:
|
||||
# This workflow contains a single job called "build"
|
||||
lint:
|
||||
# The type of runner that the job will run on
|
||||
runs-on: ubuntu-latest
|
||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
- uses: actions/checkout@v1
|
||||
- name: Setup SSH
|
||||
uses: webfactory/ssh-agent@v0.2.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.GH_SSH_KEY }}
|
||||
- name: Use Node.js 12.x
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 12.x
|
||||
- run: npm install
|
||||
- name: ESLint Action
|
||||
uses: reviewdog/action-eslint@v1
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
reporter: github-pr-review
|
||||
level: 'warning'
|
||||
2
apps/web/.gitignore
vendored
2
apps/web/.gitignore
vendored
@@ -21,3 +21,5 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
.now
|
||||
@@ -1,68 +1,3 @@
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
<h1 style="color: #1790F3; font-family: serif; font-weight: bold;">Notesnook</h1>
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `yarn start`
|
||||
|
||||
Runs the app in the development mode.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `yarn test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br />
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `yarn build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br />
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br />
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `yarn eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
|
||||
|
||||
### `yarn build` fails to minify
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
|
||||
The best notes app in the world.
|
||||
|
||||
@@ -3,29 +3,43 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@mdi/js": "^5.0.45",
|
||||
"@mdi/react": "^1.4.0",
|
||||
"@rebass/forms": "^4.0.6",
|
||||
"emotion-theming": "^10.0.19",
|
||||
"events": "^3.0.0",
|
||||
"fast-sort": "^2.1.1",
|
||||
"framer-motion": "^1.10.3",
|
||||
"immer": "^6.0.3",
|
||||
"libsodium-wrappers": "https://github.com/streetwriters/libsodium-packages.git#libsodium-wrappers",
|
||||
"localforage": "^1.7.3",
|
||||
"localforage-getitems": "https://github.com/thecodrr/localForage-getItems.git",
|
||||
"notes-core": "npm:@streetwriters/notesnook-core@latest",
|
||||
"quill": "https://github.com/thecodrr/quill.git#1.3.7",
|
||||
"quill-magic-url": "^1.0.3",
|
||||
"quill-markdown-shortcuts": "^0.0.10",
|
||||
"react": "^16.11.0",
|
||||
"react": "^16.13.1",
|
||||
"react-app-polyfill": "^1.0.6",
|
||||
"react-dom": "^16.11.0",
|
||||
"react-feather": "^2.0.3",
|
||||
"react-modal": "^3.11.1",
|
||||
"react-quill": "^1.3.3",
|
||||
"react-scripts": "3.2.0",
|
||||
"react-simple-dropdown": "^3.2.3",
|
||||
"react-virtuoso": "^0.12.3",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-modal": "^3.11.2",
|
||||
"react-scripts": "3.4.1",
|
||||
"react-virtuoso": "^0.15.0",
|
||||
"rebass": "^4.0.7",
|
||||
"timeago-react": "^3.0.0"
|
||||
"timeago-react": "^3.0.0",
|
||||
"zustand": "^2.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-loader": "8.0.6"
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-loader": "8.1.0",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-config-react-app": "^5.2.1",
|
||||
"eslint-plugin-import": "^2.20.2",
|
||||
"eslint-plugin-jsx-a11y": "^6.2.3",
|
||||
"eslint-plugin-react": "^7.19.0",
|
||||
"eslint-plugin-react-hooks": "^3.0.0",
|
||||
"source-map-explorer": "^2.4.2",
|
||||
"typescript": "^3.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
"preinstall": "chmod +x ./scripts/ci.sh && ./scripts/ci.sh",
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link
|
||||
<!-- <link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/highlightjs@9.16.2/styles/monokai.css"
|
||||
/>
|
||||
/> -->
|
||||
<link rel="apple-touch-icon" href="logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
@@ -28,11 +28,12 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
<title>Notesnook - A safe place to write</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<script src="https://cdn.jsdelivr.net/npm/highlightjs@9.16.2/highlight.pack.min.js"></script>
|
||||
<!-- <script src="https://cdn.jsdelivr.net/npm/highlightjs@9.16.2/highlight.pack.min.js"></script>
|
||||
-->
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
33
apps/web/scripts/ci.sh
Executable file
33
apps/web/scripts/ci.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# This script is meant to be triggered on "package.json:scripts:preinstall"
|
||||
# Once executed, it will allow fetching a private Github repository (the GITHUB_SSH_KEY_CDE is a RSA private key, that matches a Github "Deploy key")
|
||||
# See https://spectrum.chat/zeit/now/deploy-with-private-github-dependency~67f5de5b-ad7f-4ff0-afbe-c1b717cca4db
|
||||
|
||||
echo "Resolving deployment's origin (Zeit, GitHub Actions, AWS CodeBuild), with HOME='$HOME'"
|
||||
|
||||
function configure_ssh() {
|
||||
echo "Configuring environment with a custom ssh key to be able to fetch private dependencies..."
|
||||
|
||||
local SSH_HOME="$1"/.ssh
|
||||
echo "Using SSH_HOME='$SSH_HOME'"
|
||||
|
||||
mkdir -p "$SSH_HOME"
|
||||
|
||||
echo "${GH_DEPLOY_KEY}" >"$SSH_HOME"/id_rsa # We must use "id_rsa" file name, using a custom name will fail
|
||||
chmod 400 "$SSH_HOME"/id_rsa
|
||||
|
||||
ssh-keyscan -t rsa github.com >>"$SSH_HOME"/known_hosts # Look and make trust communications between github.com and the builder
|
||||
}
|
||||
|
||||
if [[ "$HOME" == "/zeit" ]]; then # On Zeit
|
||||
echo "Detected environment: Zeit"
|
||||
configure_ssh /root
|
||||
|
||||
elif [[ "$HOME" == "/home/runner" ]]; then # On Github Actions
|
||||
echo "Detected environment: Github Actions"
|
||||
configure_ssh "$HOME"
|
||||
|
||||
else
|
||||
echo "Detected environment: None"
|
||||
echo "This environment isn't handled (not a CI/CD env), nothing will be installed"
|
||||
fi
|
||||
@@ -1,243 +1,68 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "./app.css";
|
||||
import Editor from "./components/editor";
|
||||
import { Flex, Box, Button, Text, Heading } from "rebass";
|
||||
import { ThemeProvider } from "./utils/theme";
|
||||
import RootNavigator from "./navigation/navigators/rootnavigator";
|
||||
import React, { useEffect } from "react";
|
||||
import "./app.css";
|
||||
import { Flex, Box } from "rebass";
|
||||
import ThemeProvider from "./components/theme-provider";
|
||||
import { usePersistentState } from "./utils/hooks";
|
||||
import { ev } from "./common";
|
||||
import { useTheme } from "emotion-theming";
|
||||
|
||||
const NavMenuItem = props => {
|
||||
const [fill, setFill] = useState();
|
||||
const [toggle, setToggle] = useState(
|
||||
props.item.isToggled && props.item.isToggled()
|
||||
);
|
||||
const theme = useTheme();
|
||||
useEffect(() => {
|
||||
setFill(toggle ? theme.colors.text : props.item.color || "transparent");
|
||||
}, [props.item, toggle, theme.colors]);
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
props.onSelected();
|
||||
setToggle(props.item.isToggled && props.item.isToggled());
|
||||
}}
|
||||
variant="nav"
|
||||
sx={{
|
||||
width: "full",
|
||||
borderRadius: "none",
|
||||
textAlign: "center",
|
||||
color: props.selected ? "primary" : props.item.color || "text",
|
||||
transition: "color 100ms linear",
|
||||
":hover": {
|
||||
color: "primary"
|
||||
}
|
||||
}}
|
||||
px={0}
|
||||
py={3}
|
||||
>
|
||||
<Flex
|
||||
justifyContent={["flex-start", "center", "center"]}
|
||||
alignItems="center"
|
||||
sx={{ marginLeft: [2, 0, 0] }}
|
||||
>
|
||||
<props.item.icon
|
||||
size={"1.125rem"}
|
||||
strokeWidth={props.selected ? 2 : 1.3}
|
||||
style={{ marginRight: 2 }}
|
||||
fill={fill}
|
||||
/>
|
||||
<Text
|
||||
sx={{
|
||||
display: ["flex", "none", "none"],
|
||||
fontSize: "title",
|
||||
marginLeft: 1
|
||||
}}
|
||||
>
|
||||
{props.item.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
var startX, startWidth;
|
||||
|
||||
function getNavigationViewWidth() {
|
||||
return window.localStorage.getItem("navigationViewWidth");
|
||||
}
|
||||
import { useStore } from "./stores/app-store";
|
||||
import { useStore as useAppStore } from "./stores/app-store";
|
||||
import { useStore as useEditorStore } from "./stores/editor-store";
|
||||
import { useStore as useUserStore } from "./stores/user-store";
|
||||
import Animated from "./components/animated";
|
||||
import NavigationMenu from "./components/navigationmenu";
|
||||
import NavigationContainer from "./navigation/container";
|
||||
import RootNavigator from "./navigation/navigators/rootnavigator";
|
||||
|
||||
function App() {
|
||||
const [selectedIndex, setSelectedIndex] = usePersistentState(
|
||||
"navSelectedIndex",
|
||||
0
|
||||
);
|
||||
const [show, setShow] = usePersistentState("navContainerState", true);
|
||||
const [sideMenuOpen, setSideMenuOpen] = useState(false);
|
||||
const [show, setShow] = usePersistentState("isContainerVisible", true);
|
||||
const refreshColors = useStore((store) => store.refreshColors);
|
||||
const isFocusMode = useAppStore((store) => store.isFocusMode);
|
||||
const initUser = useUserStore((store) => store.init);
|
||||
const openLastSession = useEditorStore((store) => store.openLastSession);
|
||||
|
||||
useEffect(() => {
|
||||
RootNavigator.navigate(Object.keys(RootNavigator.routes)[selectedIndex]);
|
||||
function openSideMenu() {
|
||||
setSideMenuOpen(true);
|
||||
refreshColors();
|
||||
initUser();
|
||||
}, [refreshColors, initUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocusMode) {
|
||||
setShow(false);
|
||||
} else {
|
||||
setShow(true);
|
||||
}
|
||||
ev.addListener("openSideMenu", openSideMenu);
|
||||
return () => {
|
||||
ev.removeListener("openSideMenu", openSideMenu);
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isFocusMode]);
|
||||
|
||||
useEffect(() => {
|
||||
openLastSession();
|
||||
}, [openLastSession]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Flex
|
||||
bg={"background"}
|
||||
sx={{ color: "text" }}
|
||||
height="100%"
|
||||
alignContent="stretch"
|
||||
>
|
||||
<Box
|
||||
display={[sideMenuOpen ? "block" : "none", "none", "none"]}
|
||||
width={"full"}
|
||||
height={"full"}
|
||||
bg={"#00000099"}
|
||||
sx={{ position: "absolute", zIndex: 999 }}
|
||||
onClick={() => setSideMenuOpen(false)}
|
||||
/>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
sx={{
|
||||
zIndex: 999,
|
||||
borderRightStyle: "solid",
|
||||
borderRightWidth: [0, "1px", "1px"],
|
||||
borderRightColor: "primary",
|
||||
minWidth: ["85%", 50, 50],
|
||||
maxWidth: ["85%", 50, 50],
|
||||
height: "full",
|
||||
display: [sideMenuOpen ? "flex" : "none", "flex", "flex"],
|
||||
position: [
|
||||
sideMenuOpen ? "absolute" : "relative",
|
||||
"relative",
|
||||
"relative"
|
||||
],
|
||||
overflow: "scroll",
|
||||
scrollbarWidth: "none",
|
||||
//TODO: need to test this on webkit and internet explorer
|
||||
"::-webkit-scrollbar": { width: 0, height: 0 },
|
||||
msOverflowStyle: "none"
|
||||
//"-ms-overflow-style": "none"
|
||||
}}
|
||||
bg={["background", "shade", "shade"]}
|
||||
px={0}
|
||||
>
|
||||
{sideMenuOpen && (
|
||||
<Heading
|
||||
color="primary"
|
||||
fontSize={28}
|
||||
mx={2}
|
||||
style={{ marginTop: 2 }}
|
||||
>
|
||||
notesnook
|
||||
</Heading>
|
||||
)}
|
||||
<Flex
|
||||
flex="1 1 auto"
|
||||
flexDirection="column"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box>
|
||||
{Object.values(RootNavigator.routes).map(
|
||||
(item, index) =>
|
||||
!item.bottom && (
|
||||
<NavMenuItem
|
||||
onSelected={async () => {
|
||||
if (selectedIndex === index || !sideMenuOpen) {
|
||||
setShow(!show);
|
||||
return;
|
||||
}
|
||||
if (RootNavigator.navigate(item.key)) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
setSideMenuOpen(false);
|
||||
}}
|
||||
key={item.key}
|
||||
item={item}
|
||||
selected={selectedIndex === index}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{Object.values(RootNavigator.routes).map(
|
||||
(item, index) =>
|
||||
item.bottom && (
|
||||
<NavMenuItem
|
||||
onSelected={async () => {
|
||||
if (item.onClick) {
|
||||
return item.onClick();
|
||||
}
|
||||
if (selectedIndex === index) {
|
||||
setShow(!show);
|
||||
return;
|
||||
}
|
||||
if (RootNavigator.navigate(item.key)) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
setSideMenuOpen(false);
|
||||
}}
|
||||
key={item.key}
|
||||
item={item}
|
||||
selected={selectedIndex === index}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Flex flex="1 1 auto" flexDirection="row" alignContent="stretch" px={0}>
|
||||
<Flex
|
||||
className="RootNavigator"
|
||||
style={{
|
||||
display: show ? "flex" : "none"
|
||||
<Flex bg="background" height="100%">
|
||||
<NavigationMenu toggleNavigationContainer={() => setShow(!show)} />
|
||||
<Flex variant="rowFill">
|
||||
<Animated.Flex
|
||||
variant="columnFill"
|
||||
initial={{ width: "30%", opacity: 1, scaleY: 1 }}
|
||||
animate={{
|
||||
width: show ? "30%" : "0%",
|
||||
scaleY: show ? 1 : 0.8,
|
||||
opacity: show ? 1 : 0,
|
||||
zIndex: show ? 0 : -1,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
sx={{
|
||||
borderRight: "1px solid",
|
||||
borderColor: "border",
|
||||
width: ["100%", "100%", "30%"]
|
||||
}}
|
||||
flexDirection="column"
|
||||
flex="1 1 auto"
|
||||
//style={{ width: "362px" }}
|
||||
/>
|
||||
<Box
|
||||
className="resize-handle"
|
||||
bg="border"
|
||||
sx={{
|
||||
width: 5,
|
||||
display: ["none", "none", show ? "block" : "none"],
|
||||
opacity: 0,
|
||||
cursor: "col-resize"
|
||||
}}
|
||||
draggable={true}
|
||||
onMouseDown={e => {
|
||||
startX = e.clientX;
|
||||
let view = document
|
||||
.querySelector(".RootNavigator")
|
||||
.getBoundingClientRect();
|
||||
startWidth = parseInt(view.width, 10);
|
||||
}}
|
||||
onDrag={e => {
|
||||
let view = document.querySelector(".RootNavigator");
|
||||
view.style.width = `${startWidth + e.clientX - startX}px`;
|
||||
}}
|
||||
onDragEnd={e => {
|
||||
let view = document.querySelector(".RootNavigator");
|
||||
view.style.width = view.getBoundingClientRect().width;
|
||||
window.localStorage.setItem(
|
||||
"navigationViewWidth",
|
||||
view.style.width
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Editor />
|
||||
>
|
||||
<NavigationContainer
|
||||
navigator={RootNavigator}
|
||||
variant="columnFill"
|
||||
/>
|
||||
</Animated.Flex>
|
||||
<Flex width="100%" className="EditorNavigator" />
|
||||
</Flex>
|
||||
<Box id="dialogContainer" />
|
||||
<Box id="snackbarContainer" />
|
||||
@@ -245,5 +70,4 @@ function App() {
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import StorageInterface from "../interfaces/storage";
|
||||
import Database from "notes-core/api/";
|
||||
import events from "events";
|
||||
|
||||
export const db = new Database(StorageInterface);
|
||||
export const ev = new events.EventEmitter();
|
||||
console.log("from common", db);
|
||||
export function sendNewNoteEvent() {
|
||||
ev.emit("onNewNote");
|
||||
}
|
||||
|
||||
export const COLORS = {
|
||||
red: "#f44336",
|
||||
orange: "#FF9800",
|
||||
yellow: "#FFD600",
|
||||
green: "#4CAF50",
|
||||
blue: "#2196F3",
|
||||
purple: "#673AB7",
|
||||
gray: "#9E9E9E",
|
||||
};
|
||||
|
||||
export const DEFAULT_CONTEXT = { colors: [], tags: [], notebook: {} };
|
||||
|
||||
122
apps/web/src/common/selectionoptions.js
Normal file
122
apps/web/src/common/selectionoptions.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as Icon from "../components/icons";
|
||||
import { store as selectionStore } from "../stores/selection-store";
|
||||
import { store as notesStore } from "../stores/note-store";
|
||||
import { store as nbStore } from "../stores/notebook-store";
|
||||
import { store as editorStore } from "../stores/editor-store";
|
||||
import { store as trashStore } from "../stores/trash-store";
|
||||
import { db } from "./index";
|
||||
import { showMoveNoteDialog } from "../components/dialogs/movenotedialog";
|
||||
import { confirm } from "../components/dialogs/confirm";
|
||||
|
||||
function createOption(key, icon, onClick) {
|
||||
return {
|
||||
key,
|
||||
icon,
|
||||
onClick: async () => {
|
||||
await onClick.call(this, selectionStore.get());
|
||||
selectionStore.toggleSelectionMode(false);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOptions(options = []) {
|
||||
return [...options, DeleteOption];
|
||||
}
|
||||
|
||||
const DeleteOption = createOption("deleteOption", Icon.Trash, async function (
|
||||
state
|
||||
) {
|
||||
if (
|
||||
!(await confirm(Icon.Trash, "Delete", "Are you sure you want to proceed?"))
|
||||
)
|
||||
return;
|
||||
const item = state.selectedItems[0];
|
||||
|
||||
var isAnyNoteOpened = false;
|
||||
const items = state.selectedItems.map((item) => {
|
||||
if (item.id === editorStore.get().session.id) isAnyNoteOpened = true;
|
||||
if (item.locked) return 0;
|
||||
return item.id;
|
||||
});
|
||||
|
||||
if (item.dateDeleted) {
|
||||
// we are in trash
|
||||
await db.trash.delete(...items);
|
||||
trashStore.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAnyNoteOpened) {
|
||||
editorStore.newSession();
|
||||
}
|
||||
|
||||
if (item.type === "note") {
|
||||
await db.notes.delete(...items);
|
||||
notesStore.refresh();
|
||||
} else if (item.type === "notebook") {
|
||||
await db.notebooks.delete(...items);
|
||||
nbStore.refresh();
|
||||
} else if (item.notebookId) {
|
||||
// its a topic
|
||||
await db.notebooks.notebook(item.notebookId).topics.delete(...items);
|
||||
// TODO refresh topics
|
||||
}
|
||||
});
|
||||
|
||||
const FavoriteOption = createOption("favoriteOption", Icon.Star, function (
|
||||
state
|
||||
) {
|
||||
// we know only notes can be favorited
|
||||
state.selectedItems.forEach(async (item) => {
|
||||
if (item.favorite) return;
|
||||
await db.notes.note(item.id).favorite();
|
||||
});
|
||||
notesStore.refresh();
|
||||
});
|
||||
|
||||
const UnfavoriteOption = createOption("unfavoriteOption", Icon.Star, function (
|
||||
state
|
||||
) {
|
||||
// we know only notes can be favorited
|
||||
state.selectedItems.forEach(async (item) => {
|
||||
if (!item.favorite) return;
|
||||
await db.notes.note(item.id).favorite();
|
||||
});
|
||||
notesStore.setContext({ type: "favorites" });
|
||||
});
|
||||
|
||||
const AddToNotebookOption = createOption(
|
||||
"atnOption",
|
||||
Icon.Plus,
|
||||
async function (state) {
|
||||
const items = state.selectedItems.map((item) => item.id);
|
||||
if (await showMoveNoteDialog(items)) {
|
||||
//TODO show proper snack
|
||||
console.log("Notes moved successfully!");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const RestoreOption = createOption(
|
||||
"restoreOption",
|
||||
Icon.Restore,
|
||||
async function (state) {
|
||||
const items = state.selectedItems.map((item) => item.id);
|
||||
await db.trash.restore(...items);
|
||||
trashStore.refresh();
|
||||
}
|
||||
);
|
||||
|
||||
const NotesOptions = createOptions([AddToNotebookOption, FavoriteOption]);
|
||||
const NotebooksOptions = createOptions();
|
||||
const TopicOptions = createOptions();
|
||||
const TrashOptions = createOptions([RestoreOption]);
|
||||
const FavoritesOptions = createOptions([UnfavoriteOption]);
|
||||
|
||||
export default {
|
||||
NotebooksOptions,
|
||||
NotesOptions,
|
||||
TopicOptions,
|
||||
TrashOptions,
|
||||
FavoritesOptions,
|
||||
};
|
||||
29
apps/web/src/common/store.js
Normal file
29
apps/web/src/common/store.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import produce, { immerable } from "immer";
|
||||
import create from "zustand";
|
||||
|
||||
function immer(config) {
|
||||
return function(set, get, api) {
|
||||
const obj = config(
|
||||
fn =>
|
||||
set(() =>
|
||||
produce(get(), state => {
|
||||
fn(state);
|
||||
})
|
||||
),
|
||||
get,
|
||||
api
|
||||
);
|
||||
obj[immerable] = true;
|
||||
return obj;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {[import("zustand").UseStore<any>, any]}
|
||||
*/
|
||||
function createStore(store) {
|
||||
const [useStore, api] = create(immer(store.new.bind(store)));
|
||||
return [useStore, api.getState()];
|
||||
}
|
||||
|
||||
export default createStore;
|
||||
66
apps/web/src/common/vault.js
Normal file
66
apps/web/src/common/vault.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { db } from "../common";
|
||||
import { showPasswordDialog } from "../components/dialogs/passworddialog";
|
||||
|
||||
class Vault {
|
||||
static createVault() {
|
||||
return showPasswordDialog("create_vault", (password) =>
|
||||
db.vault.create(password)
|
||||
);
|
||||
}
|
||||
|
||||
static unlockVault() {
|
||||
return showPasswordDialog("unlock_vault", (password) => {
|
||||
return db.vault
|
||||
.unlock(password)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
});
|
||||
}
|
||||
|
||||
static unlockNote(id, done) {
|
||||
return showPasswordDialog("unlock_note", (password) => {
|
||||
return db.vault
|
||||
.remove(id, password)
|
||||
.then(() => true)
|
||||
.catch((e) => {
|
||||
if (e.message === db.vault.ERRORS.wrongPassword) return false;
|
||||
else console.error(e);
|
||||
});
|
||||
}).then((res) => res && done());
|
||||
}
|
||||
|
||||
static openNote(id) {
|
||||
return new Promise((resolve) => {
|
||||
showPasswordDialog("unlock_note", (password) => {
|
||||
return db.vault
|
||||
.open(id, password)
|
||||
.then((note) => {
|
||||
resolve(note.content);
|
||||
return true;
|
||||
})
|
||||
.catch((e) => {
|
||||
if (e.message === db.vault.ERRORS.wrongPassword) return false;
|
||||
else console.error(e);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static lockNote(id, done) {
|
||||
return db.vault
|
||||
.add(id)
|
||||
.then(done)
|
||||
.catch(({ message }) => {
|
||||
switch (message) {
|
||||
case db.vault.ERRORS.noVault:
|
||||
return Vault.createVault();
|
||||
case db.vault.ERRORS.vaultLocked:
|
||||
return Vault.unlockVault();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.then((result) => result && Vault.lockNote(id));
|
||||
}
|
||||
}
|
||||
export default Vault;
|
||||
33
apps/web/src/components/accent-item/index.js
Normal file
33
apps/web/src/components/accent-item/index.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { Flex } from "rebass";
|
||||
import * as Icon from "../icons";
|
||||
import { useStore as useThemeStore } from "../../stores/theme-store";
|
||||
|
||||
function AccentItem(props) {
|
||||
const { code, label } = props;
|
||||
const setAccent = useThemeStore(store => store.setAccent);
|
||||
const accent = useThemeStore(store => store.accent);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
variant="rowCenter"
|
||||
sx={{ position: "relative" }}
|
||||
onClick={() => setAccent(code)}
|
||||
key={label}
|
||||
>
|
||||
{code === accent && (
|
||||
<Icon.Checkmark
|
||||
sx={{
|
||||
position: "absolute",
|
||||
zIndex: 1,
|
||||
cursor: "pointer"
|
||||
}}
|
||||
color="static"
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
<Icon.Circle size={40} sx={{ cursor: "pointer" }} color={code} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default AccentItem;
|
||||
9
apps/web/src/components/animated/index.js
Normal file
9
apps/web/src/components/animated/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { motion } from "framer-motion";
|
||||
import * as Rebass from "rebass";
|
||||
|
||||
export default {
|
||||
Flex: motion.custom(Rebass.Flex),
|
||||
Box: motion.custom(Rebass.Box),
|
||||
Image: motion.custom(Rebass.Image),
|
||||
Text: motion.custom(Rebass.Text)
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
import React from "react";
|
||||
import { Flex, Text } from "rebass";
|
||||
import { ButtonPressedStyle } from "../../utils/theme";
|
||||
import { useTheme } from "emotion-theming";
|
||||
|
||||
const Button = props => {
|
||||
//TODO use normal button
|
||||
function Button(props) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Flex
|
||||
bg="primary"
|
||||
width={props.width}
|
||||
py={2}
|
||||
px={2}
|
||||
py={3}
|
||||
px={3}
|
||||
mx={2}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
@@ -24,16 +24,15 @@ const Button = props => {
|
||||
cursor: "pointer",
|
||||
bg: theme.colors.primary + "dd"
|
||||
},
|
||||
...ButtonPressedStyle,
|
||||
...props.style
|
||||
}}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.Icon && <props.Icon />}
|
||||
<Text as="span" mx={1} fontSize={14} flex="1 1 auto">
|
||||
{props.Icon && <props.Icon color="static" />}
|
||||
<Text as="span" mx={1} fontSize={"body"} flex="1 1 auto">
|
||||
{props.content}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
}
|
||||
export default Button;
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Flex, Text } from "rebass";
|
||||
import { Switch } from "@rebass/forms";
|
||||
import * as Icon from "react-feather";
|
||||
|
||||
const CheckBox = props => {
|
||||
const [checked, setChecked] = useState(props.checked || false);
|
||||
useEffect(() => {
|
||||
setChecked(props.checked);
|
||||
}, [props.checked]);
|
||||
return (
|
||||
<Flex
|
||||
onClick={() => {
|
||||
setChecked(!checked);
|
||||
if (props.onChecked) {
|
||||
props.onChecked(!checked);
|
||||
}
|
||||
}}
|
||||
width="full"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ cursor: "pointer", marginBottom: 2 }}
|
||||
>
|
||||
<Flex fontSize="body" sx={{ cursor: "pointer" }} alignItems="center">
|
||||
<props.icon size={18} />
|
||||
<Text sx={{ marginLeft: 1 }}>{props.label}</Text>
|
||||
</Flex>
|
||||
|
||||
<Switch checked={checked} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckBox;
|
||||
192
apps/web/src/components/dialogs/add-notebook-dialog.js
Normal file
192
apps/web/src/components/dialogs/add-notebook-dialog.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import React from "react";
|
||||
import { Flex, Box, Text, Button as RebassButton } from "rebass";
|
||||
import { Input } from "@rebass/forms";
|
||||
import * as Icon from "../icons";
|
||||
import Dialog, { showDialog } from "./dialog";
|
||||
import { store } from "../../stores/notebook-store";
|
||||
|
||||
class AddNotebookDialog extends React.Component {
|
||||
MAX_AVAILABLE_HEIGHT = window.innerHeight * 0.3;
|
||||
title = "";
|
||||
description = "";
|
||||
_inputRefs = [];
|
||||
lastLength = 0;
|
||||
topics = [""];
|
||||
id = undefined;
|
||||
state = {
|
||||
topics: [""],
|
||||
focusedInputIndex: 0,
|
||||
};
|
||||
|
||||
performActionOnTopic(index) {
|
||||
if (this.state.focusedInputIndex !== index) {
|
||||
this.removeTopic(index);
|
||||
} else {
|
||||
this.addTopic(index);
|
||||
}
|
||||
}
|
||||
|
||||
removeTopic(index) {
|
||||
this._action(index, 1);
|
||||
}
|
||||
|
||||
addTopic(index) {
|
||||
this._action(index + 1, 0, "");
|
||||
}
|
||||
|
||||
_action(index, deleteCount, replaceItem) {
|
||||
if (replaceItem !== undefined)
|
||||
this.topics.splice(index, deleteCount, replaceItem);
|
||||
else this.topics.splice(index, deleteCount);
|
||||
|
||||
this.setState({ topics: this.topics }, () =>
|
||||
setTimeout(() => {
|
||||
this._inputRefs[index].focus();
|
||||
}, 0)
|
||||
);
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.isOpen === false) {
|
||||
this._reset();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.notebook) return;
|
||||
const { title, description, id, topics } = this.props.notebook;
|
||||
this.setState({
|
||||
topics: topics.map((topic) => topic.title),
|
||||
});
|
||||
this.title = title;
|
||||
this.description = description;
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
_reset() {
|
||||
this.title = "";
|
||||
this.description = "";
|
||||
this._inputRefs = [];
|
||||
this.lastLength = 0;
|
||||
this.topics = [];
|
||||
this.id = undefined;
|
||||
this.setState({
|
||||
topics: [""],
|
||||
focusedInputIndex: 0,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = this.props;
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={props.isOpen}
|
||||
title="Notebook"
|
||||
icon={Icon.Notebook}
|
||||
positiveButton={{
|
||||
text: props.edit ? "Edit" : "Add",
|
||||
onClick: () => {
|
||||
props.onDone({
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
topics: this.topics,
|
||||
id: this.id,
|
||||
});
|
||||
},
|
||||
}}
|
||||
negativeButton={{ text: "Cancel", onClick: props.close }}
|
||||
>
|
||||
<Box my={1}>
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={(e) => (this.title = e.target.value)}
|
||||
placeholder="Enter name"
|
||||
defaultValue={this.title}
|
||||
/>
|
||||
<Input
|
||||
sx={{ marginTop: 1 }}
|
||||
onChange={(e) => (this.description = e.target.value)}
|
||||
placeholder="Enter description (optional)"
|
||||
defaultValue={this.description}
|
||||
/>
|
||||
<Text variant="body" fontWeight="bold" my={1}>
|
||||
Topics (optional):
|
||||
</Text>
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: this.MAX_AVAILABLE_HEIGHT,
|
||||
overflowY: "auto",
|
||||
marginBottom: 1,
|
||||
}}
|
||||
>
|
||||
{this.state.topics.map((value, index) => (
|
||||
<Flex key={value} flexDirection="row" sx={{ marginBottom: 1 }}>
|
||||
<Input
|
||||
ref={(ref) => {
|
||||
this._inputRefs[index] = ref;
|
||||
if (ref) ref.value = value; // set default value
|
||||
}}
|
||||
placeholder="Topic name"
|
||||
onFocus={(e) => {
|
||||
this.lastLength = e.nativeEvent.target.value.length;
|
||||
if (this.state.focusedInputIndex === index) return;
|
||||
this.setState({ focusedInputIndex: index });
|
||||
}}
|
||||
onChange={(e) => {
|
||||
this.topics[index] = e.target.value;
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.nativeEvent.key === "Enter") {
|
||||
this.addTopic(index);
|
||||
} else if (
|
||||
e.nativeEvent.key === "Backspace" &&
|
||||
this.lastLength === 0 &&
|
||||
index > 0
|
||||
) {
|
||||
this.removeTopic(index);
|
||||
}
|
||||
this.lastLength = e.nativeEvent.target.value.length;
|
||||
}}
|
||||
/>
|
||||
<RebassButton
|
||||
variant="tertiary"
|
||||
sx={{ marginLeft: 1 }}
|
||||
px={2}
|
||||
py={1}
|
||||
onClick={() => this.performActionOnTopic(index)}
|
||||
>
|
||||
<Box height={20}>
|
||||
{this.state.focusedInputIndex === index ? (
|
||||
<Icon.Plus size={22} />
|
||||
) : (
|
||||
<Icon.Minus size={22} />
|
||||
)}
|
||||
</Box>
|
||||
</RebassButton>
|
||||
</Flex>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function showEditNoteDialog(notebook) {
|
||||
return showDialog((perform) => (
|
||||
<AddNotebookDialog
|
||||
isOpen={true}
|
||||
notebook={notebook}
|
||||
edit={true}
|
||||
onDone={async (nb) => {
|
||||
await store.add(nb);
|
||||
perform(false);
|
||||
}}
|
||||
close={() => {
|
||||
perform(false);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
export default AddNotebookDialog;
|
||||
36
apps/web/src/components/dialogs/confirm.js
Normal file
36
apps/web/src/components/dialogs/confirm.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Box, Text } from "rebass";
|
||||
import Dialog, { showDialog } from "./dialog";
|
||||
|
||||
function Confirm(props) {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={props.title}
|
||||
icon={props.icon}
|
||||
positiveButton={{
|
||||
text: "Yes",
|
||||
onClick: props.onYes,
|
||||
}}
|
||||
negativeButton={{ text: "No", onClick: props.onNo }}
|
||||
>
|
||||
<Box my={1}>
|
||||
<Text textAlign="center" variant="body">
|
||||
{props.message}
|
||||
</Text>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function confirm(icon, title, message) {
|
||||
return showDialog((perform) => (
|
||||
<Confirm
|
||||
title={title}
|
||||
message={message}
|
||||
icon={icon}
|
||||
onNo={() => perform(false)}
|
||||
onYes={() => perform(true)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
101
apps/web/src/components/dialogs/dialog.js
Normal file
101
apps/web/src/components/dialogs/dialog.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Flex, Text, Button as RebassButton } from "rebass";
|
||||
import ThemeProvider from "../theme-provider";
|
||||
import * as Icon from "../icons";
|
||||
import Modal from "react-modal";
|
||||
|
||||
function Dialog(props) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
{(theme) => (
|
||||
<Modal
|
||||
isOpen={props.isOpen || false}
|
||||
shouldCloseOnOverlayClick={true}
|
||||
onRequestClose={props.negativeButton.onClick}
|
||||
style={{
|
||||
content: {
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
right: "auto",
|
||||
bottom: "auto",
|
||||
marginRight: "-50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
borderWidth: 0,
|
||||
borderRadius: theme.radii["default"],
|
||||
backgroundColor: theme.colors.background,
|
||||
color: theme.colors.text,
|
||||
//boxShadow: theme.shadows["3"],
|
||||
width: "25%",
|
||||
paddingRight: 20,
|
||||
paddingLeft: 20,
|
||||
overflowY: "hidden",
|
||||
},
|
||||
overlay: {
|
||||
zIndex: 999,
|
||||
background: theme.colors.overlay,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex flexDirection="column">
|
||||
<Flex variant="rowCenter" pb={2}>
|
||||
<props.icon size={props.iconSize || 38} color="primary" />
|
||||
<Text variant="heading" color="primary" mx={1}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
{props.children}
|
||||
<Flex variant="rowCenter" mt={3}>
|
||||
{props.positiveButton && (
|
||||
<RebassButton
|
||||
variant="primary"
|
||||
sx={{ opacity: props.positiveButton.disabled ? 0.7 : 1 }}
|
||||
mx={1}
|
||||
width={"50%"}
|
||||
disabled={props.positiveButton.disabled || false}
|
||||
onClick={
|
||||
!props.positiveButton.disabled
|
||||
? props.positiveButton.onClick
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{props.positiveButton.loading ? (
|
||||
<Icon.Loading rotate={true} color="static" />
|
||||
) : (
|
||||
props.positiveButton.text || "OK"
|
||||
)}
|
||||
</RebassButton>
|
||||
)}
|
||||
|
||||
{props.negativeButton && (
|
||||
<RebassButton
|
||||
variant="secondary"
|
||||
width={"50%"}
|
||||
onClick={props.negativeButton.onClick}
|
||||
>
|
||||
{props.negativeButton.text || "Cancel"}
|
||||
</RebassButton>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Modal>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
export default Dialog;
|
||||
|
||||
export function showDialog(dialog) {
|
||||
const root = document.getElementById("dialogContainer");
|
||||
const perform = (resolve, result) => {
|
||||
ReactDOM.unmountComponentAtNode(root);
|
||||
resolve(result);
|
||||
};
|
||||
if (root) {
|
||||
return new Promise((resolve) => {
|
||||
const PropDialog = dialog(perform.bind(this, resolve));
|
||||
ReactDOM.render(PropDialog, root);
|
||||
});
|
||||
}
|
||||
return Promise.reject("No element with id 'dialogContainer'");
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Flex, Box, Text, Button as RebassButton, Button } from "rebass";
|
||||
import { Input, Checkbox, Label } from "@rebass/forms";
|
||||
import * as Icon from "react-feather";
|
||||
import { ThemeProvider } from "../../utils/theme";
|
||||
import { db } from "../../common";
|
||||
import Modal from "react-modal";
|
||||
|
||||
const Dialog = props => {
|
||||
const [open, setOpen] = useState(false);
|
||||
Dialog.close = () => setOpen(false);
|
||||
useEffect(() => {
|
||||
setOpen(props.open);
|
||||
}, [props.open]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
{theme => (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
shouldCloseOnOverlayClick={true}
|
||||
onRequestClose={props.closeCick}
|
||||
style={{
|
||||
content: {
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
right: "auto",
|
||||
bottom: "auto",
|
||||
marginRight: "-50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
borderWidth: 0,
|
||||
borderRadius: theme.radii["default"],
|
||||
backgroundColor: theme.colors.background,
|
||||
color: theme.colors.text,
|
||||
boxShadow: theme.shadows["3"],
|
||||
width: "25%",
|
||||
paddingRight: 40,
|
||||
paddingLeft: 40,
|
||||
overflowY: "hidden"
|
||||
},
|
||||
overlay: {
|
||||
zIndex: 999,
|
||||
background: theme.colors.overlay
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Flex flexDirection="column">
|
||||
<Flex
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
alignSelf="center"
|
||||
justifyContent="center"
|
||||
color="primary"
|
||||
py={2}
|
||||
>
|
||||
<Box height={props.iconSize || 32}>
|
||||
<props.icon size={props.iconSize || 32} />
|
||||
</Box>
|
||||
<Text
|
||||
mx={2}
|
||||
as="span"
|
||||
variant="title"
|
||||
fontSize={22}
|
||||
textAlign="center"
|
||||
>
|
||||
{props.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
{props.content}
|
||||
<Flex
|
||||
flexDirection="row"
|
||||
my={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{props.positiveButton && (
|
||||
<RebassButton
|
||||
variant="primary"
|
||||
sx={{ opacity: props.positiveButton.disabled ? 0.7 : 1 }}
|
||||
mx={1}
|
||||
width={"25%"}
|
||||
disabled={props.positiveButton.disabled || false}
|
||||
onClick={props.positiveButton.click}
|
||||
>
|
||||
{props.positiveButton.text || "OK"}
|
||||
</RebassButton>
|
||||
)}
|
||||
|
||||
{props.negativeButton && (
|
||||
<RebassButton
|
||||
variant="secondary"
|
||||
width={"25%"}
|
||||
onClick={props.negativeButton.onClick}
|
||||
>
|
||||
{props.negativeButton.text || "Cancel"}
|
||||
</RebassButton>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Modal>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const inputRefs = [];
|
||||
export const CreateNotebookDialog = props => {
|
||||
const [topics, setTopics] = useState([""]);
|
||||
const addTopic = index => {
|
||||
topics.splice(index + 1, 0, "");
|
||||
setTopics([...topics]);
|
||||
setTimeout(() => {
|
||||
inputRefs[index + 1].focus();
|
||||
}, 0);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
title="Notebook"
|
||||
icon={Icon.BookOpen}
|
||||
content={
|
||||
<Box my={1}>
|
||||
<Input
|
||||
variant="default"
|
||||
onChange={e => (CreateNotebookDialog.title = e.target.value)}
|
||||
placeholder="Enter name"
|
||||
/>
|
||||
<Input
|
||||
variant="default"
|
||||
sx={{ marginTop: 1 }}
|
||||
onChange={e => (CreateNotebookDialog.description = e.target.value)}
|
||||
placeholder="Enter description (optional)"
|
||||
/>
|
||||
<Label alignItems="center" my={1}>
|
||||
<Checkbox variant="checkbox" />
|
||||
Locked?
|
||||
</Label>
|
||||
<Text variant="body" fontWeight="bold" my={1}>
|
||||
Topics (optional):
|
||||
</Text>
|
||||
<Box sx={{ maxHeight: 44 * 5, overflowY: "auto", marginBottom: 1 }}>
|
||||
{topics.map((value, index) => (
|
||||
<Flex
|
||||
key={index.toString()}
|
||||
flexDirection="row"
|
||||
sx={{ marginBottom: 1 }}
|
||||
>
|
||||
<Input
|
||||
ref={ref => (inputRefs[index] = ref)}
|
||||
variant="default"
|
||||
value={topics[index]}
|
||||
placeholder="Topic name"
|
||||
onFocus={e => {
|
||||
CreateNotebookDialog.lastLength =
|
||||
e.nativeEvent.target.value.length;
|
||||
}}
|
||||
onChange={e => {
|
||||
topics[index] = e.target.value;
|
||||
setTopics([...topics]);
|
||||
}}
|
||||
onKeyUp={e => {
|
||||
if (e.nativeEvent.key === "Enter") {
|
||||
addTopic(index);
|
||||
} else if (
|
||||
e.nativeEvent.key === "Backspace" &&
|
||||
CreateNotebookDialog.lastLength === 0 &&
|
||||
index > 0
|
||||
) {
|
||||
topics.splice(index, 1);
|
||||
setTopics([...topics]);
|
||||
setTimeout(() => {
|
||||
inputRefs[index - 1].focus();
|
||||
}, 0);
|
||||
}
|
||||
CreateNotebookDialog.lastLength =
|
||||
e.nativeEvent.target.value.length;
|
||||
}}
|
||||
/>
|
||||
<RebassButton
|
||||
variant="tertiary"
|
||||
sx={{ marginLeft: 1 }}
|
||||
px={2}
|
||||
py={1}
|
||||
onClick={() => addTopic(index)}
|
||||
>
|
||||
<Box height={20}>
|
||||
<Icon.Plus size={20} />
|
||||
</Box>
|
||||
</RebassButton>
|
||||
</Flex>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
positiveButton={{
|
||||
text: "Done",
|
||||
click: () =>
|
||||
props.onDone(
|
||||
topics,
|
||||
CreateNotebookDialog.title,
|
||||
CreateNotebookDialog.description
|
||||
)
|
||||
}}
|
||||
negativeButton={{ text: "Cancel", click: props.close }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmationDialog = props => (
|
||||
<Dialog
|
||||
open={true}
|
||||
title={props.title}
|
||||
icon={props.icon}
|
||||
content={
|
||||
<Box my={1}>
|
||||
<Text>{props.message}</Text>
|
||||
</Box>
|
||||
}
|
||||
positiveButton={{
|
||||
text: "Yes",
|
||||
click: props.onYes
|
||||
}}
|
||||
negativeButton={{ text: "No", click: props.onNo }}
|
||||
/>
|
||||
);
|
||||
|
||||
const LoginDialog = props => (
|
||||
<Dialog
|
||||
open={true}
|
||||
title={props.title}
|
||||
icon={props.icon}
|
||||
onCloseClick={props.onCloseClick}
|
||||
content={
|
||||
<Box my={1}>
|
||||
<Input variant="default" placeholder="Email"></Input>
|
||||
<Input
|
||||
variant="default"
|
||||
placeholder="Password"
|
||||
sx={{ marginTop: 2 }}
|
||||
></Input>
|
||||
<Button width={1} my={2}>
|
||||
Login
|
||||
</Button>
|
||||
<Flex flexDirection="row" justifyContent="space-between">
|
||||
<Button variant="links">Create a New Account</Button>
|
||||
<Button variant="links" alignItems="right">
|
||||
Forgot password?
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
export const showSignInDialog = (icon, title, message) => {
|
||||
const root = document.getElementById("dialogContainer");
|
||||
const perform = (result, resolve) => {
|
||||
Dialog.close();
|
||||
ReactDOM.unmountComponentAtNode(root);
|
||||
resolve(result);
|
||||
};
|
||||
if (root) {
|
||||
return new Promise((resolve, _) => {
|
||||
ReactDOM.render(
|
||||
<LoginDialog
|
||||
onCloseClick={() => {
|
||||
perform(false, resolve);
|
||||
}}
|
||||
title={title}
|
||||
message={message}
|
||||
icon={icon}
|
||||
/>,
|
||||
root
|
||||
);
|
||||
});
|
||||
}
|
||||
return Promise.reject("No element with id 'dialogContainer'");
|
||||
};
|
||||
|
||||
export const ask = (icon, title, message) => {
|
||||
const root = document.getElementById("dialogContainer");
|
||||
const perform = (result, resolve) => {
|
||||
Dialog.close();
|
||||
ReactDOM.unmountComponentAtNode(root);
|
||||
resolve(result);
|
||||
};
|
||||
if (root) {
|
||||
return new Promise((resolve, _) => {
|
||||
ReactDOM.render(
|
||||
<ConfirmationDialog
|
||||
id="confirmationDialog"
|
||||
title={title}
|
||||
message={message}
|
||||
icon={icon}
|
||||
onNo={() => perform(false, resolve)}
|
||||
onYes={() => perform(true, resolve)}
|
||||
/>,
|
||||
root
|
||||
);
|
||||
});
|
||||
}
|
||||
return Promise.reject("No element with id 'dialogContainer'");
|
||||
};
|
||||
|
||||
export const MoveDialog = props => {
|
||||
const [items, setItems] = useState(db.notebooks.all);
|
||||
const [type, setType] = useState("notebooks");
|
||||
const [title, setTitle] = useState("Notebooks");
|
||||
const [mode, setMode] = useState("read");
|
||||
useEffect(() => {
|
||||
MoveDialog.last = [];
|
||||
}, []);
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
title={"Move Note"}
|
||||
icon={Icon.Move}
|
||||
content={
|
||||
<Box>
|
||||
<Flex alignContent="center" justifyContent="space-between" my={1}>
|
||||
<Flex>
|
||||
{type !== "notebooks" && (
|
||||
<Text
|
||||
onClick={() => {
|
||||
let item = MoveDialog.last.pop();
|
||||
setType(item.type);
|
||||
setTitle(item.title);
|
||||
setItems(item.items);
|
||||
}}
|
||||
sx={{
|
||||
":hover": { color: "primary" },
|
||||
marginRight: 2
|
||||
}}
|
||||
>
|
||||
<Icon.ArrowLeft />
|
||||
</Text>
|
||||
)}
|
||||
<Text variant="title">{title}</Text>
|
||||
</Flex>
|
||||
{type !== "notes" && (
|
||||
<Text
|
||||
onClick={() => {
|
||||
if (mode === "write") {
|
||||
setMode("read");
|
||||
return;
|
||||
}
|
||||
setMode("write");
|
||||
setTimeout(() => {
|
||||
MoveDialog.inputRef.focus();
|
||||
}, 0);
|
||||
}}
|
||||
sx={{
|
||||
":hover": { color: "primary" }
|
||||
}}
|
||||
>
|
||||
{mode === "read" ? <Icon.Plus /> : <Icon.Minus />}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Input
|
||||
ref={ref => (MoveDialog.inputRef = ref)}
|
||||
variant="default"
|
||||
sx={{ display: mode === "write" ? "block" : "none" }}
|
||||
my={1}
|
||||
placeholder={type === "notebooks" ? "Notebook name" : "Topic name"}
|
||||
onKeyUp={async e => {
|
||||
if (e.nativeEvent.key === "Enter" && e.target.value.length > 0) {
|
||||
if (type === "notebooks") {
|
||||
await db.notebooks.add({
|
||||
title: e.target.value
|
||||
});
|
||||
setItems(db.notebooks.all);
|
||||
} else {
|
||||
await db.notebooks
|
||||
.notebook(MoveDialog.notebook.id)
|
||||
.topics.add(e.target.value);
|
||||
setItems(
|
||||
db.notebooks.notebook(MoveDialog.notebook.id).topics
|
||||
);
|
||||
}
|
||||
MoveDialog.inputRef.value = "";
|
||||
setMode("read");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: "border",
|
||||
maxHeight: 8 * 30,
|
||||
overflowY: "auto"
|
||||
}}
|
||||
>
|
||||
{items.length ? (
|
||||
items.map(v => {
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
borderWidth: 1,
|
||||
padding: 2,
|
||||
borderBottomColor: "border",
|
||||
borderBottomStyle: "solid",
|
||||
":hover": { borderBottomColor: "primary" }
|
||||
}}
|
||||
onClick={() => {
|
||||
MoveDialog.last.push({
|
||||
title: title,
|
||||
items: items,
|
||||
type: type
|
||||
});
|
||||
if (type === "notebooks") {
|
||||
setType("topics");
|
||||
MoveDialog.notebook = v;
|
||||
setTitle(v.title);
|
||||
setItems(v.topics);
|
||||
} else if (type === "topics") {
|
||||
setType("notes");
|
||||
MoveDialog.topic = v.title;
|
||||
setTitle(`${MoveDialog.notebook.title} - ${v.title}`);
|
||||
setItems(
|
||||
db.notebooks
|
||||
.notebook(MoveDialog.notebook.id)
|
||||
.topics.topic(v.title).all
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text sx={{ width: "80%" }}>{v.title}</Text>
|
||||
{v.totalNotes !== undefined && (
|
||||
<Text sx={{ width: "20%", textAlign: "right" }}>
|
||||
{v.totalNotes + " Notes"}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text
|
||||
py={2}
|
||||
px={2}
|
||||
sx={{ textAlign: "center", fontStyle: "italic" }}
|
||||
>
|
||||
Nothing here
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
positiveButton={{
|
||||
text: "Move",
|
||||
click: async () => {
|
||||
try {
|
||||
await db.notes.move(
|
||||
{ id: MoveDialog.notebook.id, topic: MoveDialog.topic },
|
||||
props.noteId
|
||||
);
|
||||
props.onMove();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} finally {
|
||||
props.onClose();
|
||||
}
|
||||
},
|
||||
disabled: type !== "notes"
|
||||
}}
|
||||
negativeButton={{ text: "Cancel", onClick: props.onClose }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const moveNote = (noteId, notebook) => {
|
||||
const root = document.getElementById("dialogContainer");
|
||||
const perform = (result, resolve) => {
|
||||
Dialog.close();
|
||||
ReactDOM.unmountComponentAtNode(root);
|
||||
resolve(result);
|
||||
};
|
||||
if (root) {
|
||||
return new Promise((resolve, _) => {
|
||||
ReactDOM.render(
|
||||
<MoveDialog
|
||||
noteId={noteId}
|
||||
notebook={notebook}
|
||||
onClose={() => perform(false, resolve)}
|
||||
onMove={() => perform(true, resolve)}
|
||||
/>,
|
||||
root
|
||||
);
|
||||
});
|
||||
}
|
||||
return Promise.reject("No element with id 'dialogContainer'");
|
||||
};
|
||||
62
apps/web/src/components/dialogs/logindialog.js
Normal file
62
apps/web/src/components/dialogs/logindialog.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, Button, Text } from "rebass";
|
||||
import Input from "../inputs";
|
||||
import * as Icon from "../icons";
|
||||
import Dialog, { showDialog } from "./dialog";
|
||||
import { showSignUpDialog } from "./signupdialog";
|
||||
import { useStore } from "../../stores/user-store";
|
||||
import PasswordInput from "../inputs/password";
|
||||
import Dropper from "../dropper";
|
||||
|
||||
const form = { error: true };
|
||||
function LoginDialog(props) {
|
||||
const { onClose } = props;
|
||||
const [error, setError] = useState();
|
||||
const isLoggingIn = useStore((store) => store.isLoggingIn);
|
||||
const login = useStore((store) => store.login);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={"Login"}
|
||||
icon={Icon.Login}
|
||||
onCloseClick={onClose}
|
||||
negativeButton={{ onClick: onClose }}
|
||||
positiveButton={{
|
||||
text: "Login",
|
||||
loading: isLoggingIn,
|
||||
disabled: isLoggingIn,
|
||||
onClick: () => submit(setError, form, login, onClose),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
mt={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit(setError, form, login, onClose);
|
||||
}}
|
||||
>
|
||||
<Dropper mt={2} form={form}>
|
||||
<Input autoFocus name="username" title="Username" />
|
||||
<PasswordInput />
|
||||
</Dropper>
|
||||
<Button variant="anchor" onClick={showSignUpDialog}>
|
||||
Create a New Account
|
||||
</Button>
|
||||
{error && <Text variant="error">{error}</Text>}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function submit(setError, form, login, onClose) {
|
||||
setError();
|
||||
if (form.error) return;
|
||||
|
||||
login(form)
|
||||
.then(onClose)
|
||||
.catch((e) => setError(e.message));
|
||||
}
|
||||
|
||||
export const showLogInDialog = () => {
|
||||
return showDialog((perform) => <LoginDialog onClose={() => perform()} />);
|
||||
};
|
||||
201
apps/web/src/components/dialogs/move-note-dialog.js
Normal file
201
apps/web/src/components/dialogs/move-note-dialog.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import React from "react";
|
||||
import { Flex, Box, Text } from "rebass";
|
||||
import { Input } from "@rebass/forms";
|
||||
import * as Icon from "../icons";
|
||||
import { db } from "../../common";
|
||||
import Dialog, { showDialog } from "./dialog";
|
||||
import { toTitleCase } from "../../utils/string";
|
||||
|
||||
class MoveDialog extends React.Component {
|
||||
history = [];
|
||||
_inputRef;
|
||||
selectedNotebook;
|
||||
selectedTopic;
|
||||
state = {
|
||||
items: db.notebooks.all,
|
||||
type: "notebooks",
|
||||
title: "Notebooks",
|
||||
mode: "read",
|
||||
};
|
||||
|
||||
render() {
|
||||
const { items, type, title, mode } = this.state;
|
||||
const props = this.props;
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={
|
||||
type === "notes"
|
||||
? "Move Note"
|
||||
: "Select " + toTitleCase(type.substring(0, type.length - 1))
|
||||
}
|
||||
icon={
|
||||
type === "notes"
|
||||
? Icon.Move
|
||||
: type === "topics"
|
||||
? Icon.Topic
|
||||
: Icon.Notebook
|
||||
}
|
||||
positiveButton={{
|
||||
text: "Move",
|
||||
onClick: async () => {
|
||||
try {
|
||||
await db.notes.move(
|
||||
{ id: this.selectedNotebook.id, topic: this.selectedTopic },
|
||||
...props.noteIds
|
||||
);
|
||||
props.onMove();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
props.onClose();
|
||||
}
|
||||
},
|
||||
disabled: type !== "notes",
|
||||
}}
|
||||
negativeButton={{ text: "Cancel", onClick: props.onClose }}
|
||||
>
|
||||
<Box>
|
||||
<Flex alignContent="center" justifyContent="space-between" my={1}>
|
||||
<Flex>
|
||||
<Text
|
||||
onClick={() => {
|
||||
let item = this.history.pop();
|
||||
this.setState({ ...item });
|
||||
}}
|
||||
sx={{
|
||||
display: this.history.length ? "block" : "none",
|
||||
":hover": { color: "primary" },
|
||||
marginRight: 2,
|
||||
}}
|
||||
>
|
||||
<Icon.ArrowLeft />
|
||||
</Text>
|
||||
<Text variant="title">{title}</Text>
|
||||
</Flex>
|
||||
<Text
|
||||
onClick={() => {
|
||||
if (mode === "write") {
|
||||
this.setState({ mode: "read" });
|
||||
return;
|
||||
}
|
||||
this.setState({ mode: "write" });
|
||||
setTimeout(() => {
|
||||
this._inputRef.focus();
|
||||
}, 0);
|
||||
}}
|
||||
sx={{
|
||||
display: type === "notes" ? "none" : "block",
|
||||
":hover": { color: "primary" },
|
||||
}}
|
||||
>
|
||||
{mode === "read" ? <Icon.Plus /> : <Icon.Minus />}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Input
|
||||
ref={(ref) => (this._inputRef = ref)}
|
||||
sx={{ display: mode === "write" ? "block" : "none" }}
|
||||
my={1}
|
||||
placeholder={type === "notebooks" ? "Notebook name" : "Topic name"}
|
||||
onKeyUp={async (e) => {
|
||||
if (e.nativeEvent.key === "Enter" && e.target.value.length > 0) {
|
||||
if (type === "notebooks") {
|
||||
await db.notebooks.add({
|
||||
title: e.target.value,
|
||||
});
|
||||
this.setState({ items: db.notebooks.all });
|
||||
} else {
|
||||
await db.notebooks
|
||||
.notebook(this.selectedNotebook.id)
|
||||
.topics.add(e.target.value);
|
||||
this.setState({
|
||||
items: db.notebooks.notebook(this.selectedNotebook.id)
|
||||
.topics.all,
|
||||
});
|
||||
}
|
||||
this._inputRef.value = "";
|
||||
this.setState({ mode: "read" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: "border",
|
||||
maxHeight: 8 * 30,
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
return (
|
||||
<Flex
|
||||
key={item.title + item.dateCreated}
|
||||
sx={{
|
||||
borderWidth: 1,
|
||||
padding: 2,
|
||||
borderBottomColor: "border",
|
||||
borderBottomStyle: "solid",
|
||||
cursor: "pointer",
|
||||
":hover": { borderBottomColor: "primary" },
|
||||
}}
|
||||
onClick={() => {
|
||||
this.history.push({
|
||||
title,
|
||||
items,
|
||||
type,
|
||||
});
|
||||
if (type === "notebooks") {
|
||||
this.setState({
|
||||
type: "topics",
|
||||
items: item.topics,
|
||||
title: item.title,
|
||||
});
|
||||
this.selectedNotebook = item;
|
||||
} else if (type === "topics") {
|
||||
this.setState({
|
||||
type: "notes",
|
||||
title: `${this.selectedNotebook.title} - ${item.title}`,
|
||||
items: db.notebooks
|
||||
.notebook(this.selectedNotebook.id)
|
||||
.topics.topic(item.title).all,
|
||||
});
|
||||
this.selectedTopic = item.title;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text sx={{ width: "80%" }}>{item.title}</Text>
|
||||
{item.totalNotes !== undefined && (
|
||||
<Text sx={{ width: "20%", textAlign: "right" }}>
|
||||
{item.totalNotes + " Notes"}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text
|
||||
py={2}
|
||||
px={2}
|
||||
sx={{ textAlign: "center", fontStyle: "italic" }}
|
||||
>
|
||||
Nothing here
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function showMoveNoteDialog(noteIds) {
|
||||
return showDialog((perform) => (
|
||||
<MoveDialog
|
||||
noteIds={noteIds}
|
||||
onClose={() => perform(false)}
|
||||
onMove={() => perform(true)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
95
apps/web/src/components/dialogs/password-dialog.js
Normal file
95
apps/web/src/components/dialogs/password-dialog.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { Box, Text, Flex } from "rebass";
|
||||
import { Input } from "@rebass/forms";
|
||||
import Dialog, { showDialog } from "./dialog";
|
||||
import * as Icon from "../icons";
|
||||
|
||||
function PasswordDialog(props) {
|
||||
const [isWrong, setIsWrong] = useState(false);
|
||||
const passwordRef = useRef();
|
||||
const submit = useCallback(async () => {
|
||||
const password = passwordRef.current.value;
|
||||
if (await props.validate(password)) {
|
||||
props.onDone();
|
||||
} else {
|
||||
setIsWrong(true);
|
||||
passwordRef.current.focus();
|
||||
}
|
||||
}, [setIsWrong, props]);
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={props.title}
|
||||
icon={props.icon}
|
||||
positiveButton={{
|
||||
text: props.positiveButtonText,
|
||||
onClick: submit,
|
||||
}}
|
||||
negativeButton={{ text: "Cancel", onClick: props.onCancel }}
|
||||
>
|
||||
<Box my={1}>
|
||||
<Input
|
||||
ref={passwordRef}
|
||||
autoFocus
|
||||
variant={isWrong ? "error" : "input"}
|
||||
type="password"
|
||||
placeholder="Enter vault password"
|
||||
onKeyUp={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
await submit();
|
||||
} else {
|
||||
setIsWrong(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isWrong && (
|
||||
<Flex alignItems="center" color="error" mt={2}>
|
||||
<Icon.Alert size={16} color="error" />
|
||||
<Text ml={1} fontSize={"subBody"}>
|
||||
Wrong password
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getDialogData(type) {
|
||||
switch (type) {
|
||||
case "create_vault":
|
||||
return {
|
||||
title: "Set Up Your Vault",
|
||||
icon: Icon.Vault,
|
||||
positiveButtonText: "Done",
|
||||
};
|
||||
case "unlock_vault":
|
||||
return {
|
||||
title: "Unlock Vault",
|
||||
icon: Icon.Unlock,
|
||||
positiveButtonText: "Unlock",
|
||||
};
|
||||
case "unlock_note":
|
||||
return {
|
||||
title: "Unlock Note",
|
||||
icon: Icon.Unlock,
|
||||
positiveButtonText: "Unlock",
|
||||
};
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function showPasswordDialog(type, validate) {
|
||||
const { title, icon, positiveButtonText } = getDialogData(type);
|
||||
return showDialog((perform) => (
|
||||
<PasswordDialog
|
||||
title={title}
|
||||
icon={icon}
|
||||
positiveButtonText={positiveButtonText}
|
||||
onCancel={() => perform(false)}
|
||||
validate={validate}
|
||||
onDone={() => perform(true)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
64
apps/web/src/components/dialogs/signupdialog.js
Normal file
64
apps/web/src/components/dialogs/signupdialog.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState } from "react";
|
||||
import { Text, Box } from "rebass";
|
||||
import Input from "../inputs";
|
||||
import * as Icon from "../icons";
|
||||
import Dialog, { showDialog } from "./dialog";
|
||||
//import { db } from "../../common";
|
||||
import EmailInput from "../inputs/email";
|
||||
import PasswordInput from "../inputs/password";
|
||||
import Dropper from "../dropper";
|
||||
import { useStore } from "../../stores/user-store";
|
||||
|
||||
const form = { error: true };
|
||||
function SignUpDialog(props) {
|
||||
const { onClose } = props;
|
||||
const [error, setError] = useState();
|
||||
const isSigningIn = useStore((store) => store.isSigningIn);
|
||||
const signup = useStore((store) => store.signup);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={"Sign Up"}
|
||||
icon={Icon.User}
|
||||
onCloseClick={onClose}
|
||||
negativeButton={{ onClick: onClose }}
|
||||
positiveButton={{
|
||||
text: "Sign Up",
|
||||
loading: isSigningIn,
|
||||
disabled: isSigningIn,
|
||||
onClick: () => submit(setError, form, signup, onClose),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
mt={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit(setError, form, signup, onClose);
|
||||
}}
|
||||
>
|
||||
<Dropper mt={2} form={form}>
|
||||
<Input autoFocus title="Username" name="username" />
|
||||
<EmailInput />
|
||||
<PasswordInput confirm />
|
||||
</Dropper>
|
||||
{error && <Text variant="error">{error}</Text>}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function submit(setError, form, signup, onClose) {
|
||||
setError();
|
||||
if (form.password !== form.confirm) {
|
||||
form.error = true;
|
||||
setError("Passwords do not match.");
|
||||
}
|
||||
if (form.error) return;
|
||||
signup(form)
|
||||
.then(onClose)
|
||||
.catch((e) => setError(e.message));
|
||||
}
|
||||
|
||||
export function showSignUpDialog() {
|
||||
return showDialog((perform) => <SignUpDialog onClose={() => perform()} />);
|
||||
}
|
||||
50
apps/web/src/components/dialogs/topicdialog.js
Normal file
50
apps/web/src/components/dialogs/topicdialog.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box } from "rebass";
|
||||
import { Input } from "@rebass/forms";
|
||||
import * as Icon from "../icons";
|
||||
import { db } from "../../common";
|
||||
import Dialog, { showDialog } from "./dialog";
|
||||
import { store } from "../../stores/notebook-store";
|
||||
|
||||
function TopicDialog(props) {
|
||||
const [topic, setTopic] = useState();
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={true}
|
||||
title={props.title}
|
||||
icon={props.icon}
|
||||
positiveButton={{
|
||||
text: "Add",
|
||||
onClick: props.onYes.bind(this, topic),
|
||||
}}
|
||||
negativeButton={{ text: "Cancel", onClick: props.onNo }}
|
||||
>
|
||||
<Box my={1}>
|
||||
<Input
|
||||
placeholder="Topic title"
|
||||
onChange={(e) => {
|
||||
setTopic(e.target.value);
|
||||
}}
|
||||
></Input>
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function showTopicDialog(notebook) {
|
||||
return showDialog((perform) => (
|
||||
<TopicDialog
|
||||
title={"Topic"}
|
||||
icon={Icon.Topic}
|
||||
onNo={() => {
|
||||
perform(false);
|
||||
}}
|
||||
onYes={async (topic) => {
|
||||
if (!topic) return;
|
||||
await db.notebooks.notebook(notebook).topics.add(topic);
|
||||
store.setSelectedNotebookTopics(notebook);
|
||||
perform(true);
|
||||
}}
|
||||
/>
|
||||
));
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import React, { Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
class DropdownContent extends Component {
|
||||
render() {
|
||||
const { children, className, ...dropdownContentProps } = this.props;
|
||||
dropdownContentProps.className = `dropdown__content ${className}`;
|
||||
|
||||
return <div {...dropdownContentProps}>{children}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
DropdownContent.displayName = "DropdownContent";
|
||||
|
||||
DropdownContent.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
DropdownContent.defaultProps = {
|
||||
className: ""
|
||||
};
|
||||
|
||||
export default DropdownContent;
|
||||
@@ -1,28 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class DropdownTrigger extends Component {
|
||||
render () {
|
||||
const { children, className, ...dropdownTriggerProps } = this.props;
|
||||
dropdownTriggerProps.className = `dropdown__trigger ${className}`;
|
||||
|
||||
return (
|
||||
<a {...dropdownTriggerProps}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DropdownTrigger.displayName = 'DropdownTrigger';
|
||||
|
||||
DropdownTrigger.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
DropdownTrigger.defaultProps = {
|
||||
className: ''
|
||||
};
|
||||
|
||||
export default DropdownTrigger;
|
||||
@@ -1,153 +0,0 @@
|
||||
import React, { cloneElement, Component } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { findDOMNode } from "react-dom";
|
||||
import cx from "classnames";
|
||||
|
||||
import DropdownTrigger from "./dropdown-trigger";
|
||||
import DropdownContent from "./dropdown-content";
|
||||
import "./styles/Dropdown.css";
|
||||
|
||||
var lastOpenedDropdown;
|
||||
|
||||
class Dropdown extends Component {
|
||||
displayName = "Dropdown";
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("click", this._onWindowClick);
|
||||
window.addEventListener("touchstart", this._onWindowClick);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("click", this._onWindowClick);
|
||||
window.removeEventListener("touchstart", this._onWindowClick);
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
active: false
|
||||
};
|
||||
|
||||
this._onWindowClick = this._onWindowClick.bind(this);
|
||||
this._onToggleClick = this._onToggleClick.bind(this);
|
||||
}
|
||||
|
||||
isActive() {
|
||||
return typeof this.props.active === "boolean"
|
||||
? this.props.active
|
||||
: this.state.active;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.setState(
|
||||
{
|
||||
active: false
|
||||
},
|
||||
() => {
|
||||
if (this.props.onHide) {
|
||||
this.props.onHide();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
show() {
|
||||
this.setState(
|
||||
{
|
||||
active: true
|
||||
},
|
||||
() => {
|
||||
if (this.props.onShow) {
|
||||
this.props.onShow();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_onWindowClick(event) {
|
||||
const dropdownElement = findDOMNode(this);
|
||||
if (
|
||||
event.target !== dropdownElement &&
|
||||
!dropdownElement.contains(event.target) &&
|
||||
this.isActive()
|
||||
) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
_onToggleClick(event) {
|
||||
event.preventDefault();
|
||||
if (lastOpenedDropdown) {
|
||||
lastOpenedDropdown.hide();
|
||||
}
|
||||
if (this.isActive()) {
|
||||
this.hide();
|
||||
} else {
|
||||
lastOpenedDropdown = this;
|
||||
this.show();
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, className, disabled, removeElement } = this.props;
|
||||
// create component classes
|
||||
const active = this.isActive();
|
||||
const dropdownClasses = cx({
|
||||
dropdown: true,
|
||||
"dropdown--active": active,
|
||||
"dropdown--disabled": disabled
|
||||
});
|
||||
// stick callback on trigger element
|
||||
const boundChildren = React.Children.map(children, child => {
|
||||
if (child.type === DropdownTrigger) {
|
||||
const originalOnClick = child.props.onClick;
|
||||
child = cloneElement(child, {
|
||||
ref: "trigger",
|
||||
onClick: event => {
|
||||
if (!disabled) {
|
||||
this._onToggleClick(event);
|
||||
if (originalOnClick) {
|
||||
originalOnClick.apply(child, arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (child.type === DropdownContent && removeElement && !active) {
|
||||
child = null;
|
||||
}
|
||||
return child;
|
||||
});
|
||||
const cleanProps = { ...this.props };
|
||||
delete cleanProps.active;
|
||||
delete cleanProps.onShow;
|
||||
delete cleanProps.onHide;
|
||||
delete cleanProps.removeElement;
|
||||
|
||||
return (
|
||||
<div {...cleanProps} className={`${dropdownClasses} ${className}`}>
|
||||
{boundChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Dropdown.propTypes = {
|
||||
disabled: PropTypes.bool,
|
||||
active: PropTypes.bool,
|
||||
onHide: PropTypes.func,
|
||||
onShow: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
removeElement: PropTypes.bool,
|
||||
style: PropTypes.object
|
||||
};
|
||||
|
||||
Dropdown.defaultProps = {
|
||||
className: ""
|
||||
};
|
||||
|
||||
export { DropdownTrigger, DropdownContent };
|
||||
export default Dropdown;
|
||||
@@ -1,13 +0,0 @@
|
||||
.dropdown {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dropdown__content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.dropdown--active .dropdown__content {
|
||||
display: block;
|
||||
}
|
||||
|
||||
12
apps/web/src/components/dropper/index.js
Normal file
12
apps/web/src/components/dropper/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
function Dropper(props) {
|
||||
const { children } = props;
|
||||
const fillChildren = React.Children.map(children, (child, index) => {
|
||||
if (!child) return;
|
||||
const childProps = { ...props, children: child.props.children };
|
||||
return React.cloneElement(child, childProps);
|
||||
});
|
||||
return <React.Fragment>{fillChildren}</React.Fragment>;
|
||||
}
|
||||
export default Dropper;
|
||||
@@ -8,7 +8,7 @@
|
||||
}
|
||||
|
||||
.ql-container.ql-snow {
|
||||
overflow-y: none;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/*Quill Toolbar*/
|
||||
@@ -30,12 +30,16 @@
|
||||
.ql-toolbar.ql-snow {
|
||||
width: 100%;
|
||||
border: none !important;
|
||||
border-bottom: 1px solid var(--border) !important;
|
||||
border-top: 1px solid var(--border) !important;
|
||||
padding: 5px !important;
|
||||
padding-left: 0px !important;
|
||||
}
|
||||
|
||||
/*Color Overrides*/
|
||||
.ql-toolbar.ql-snow svg {
|
||||
fill: var(--text) !important;
|
||||
}
|
||||
|
||||
.ql-snow.ql-toolbar button:hover,
|
||||
.ql-snow .ql-toolbar button:hover,
|
||||
.ql-snow.ql-toolbar button:focus,
|
||||
@@ -47,6 +51,7 @@
|
||||
.ql-snow.ql-toolbar .ql-picker-label.ql-active,
|
||||
.ql-snow .ql-toolbar .ql-picker-label.ql-active {
|
||||
color: var(--primary) !important;
|
||||
fill: var(--primary) !important;
|
||||
transition: color 300ms linear;
|
||||
}
|
||||
|
||||
@@ -79,6 +84,7 @@
|
||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter,
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter {
|
||||
stroke: var(--primary) !important;
|
||||
fill: var(--primary) !important;
|
||||
}
|
||||
|
||||
.ql-snow.ql-toolbar button:hover .ql-fill,
|
||||
@@ -110,6 +116,7 @@
|
||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill,
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill {
|
||||
fill: var(--primary) !important;
|
||||
stroke: var(--primary) !important;
|
||||
}
|
||||
|
||||
.ql-snow.ql-toolbar :not(.ql-background).ql-picker .ql-picker-item:hover,
|
||||
@@ -140,3 +147,33 @@
|
||||
background-color: var(--accent) !important;
|
||||
padding: 5px 0px !important;
|
||||
}
|
||||
|
||||
.ql-editor.ql-blank::before {
|
||||
color: var(--placeholder) !important;
|
||||
}
|
||||
|
||||
.ql-editor .ql-syntax,
|
||||
code {
|
||||
font-family: monospace !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ql-editor .ql-syntax * {
|
||||
font-family: monospace !important;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ql-editor {
|
||||
font-size: 16px;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.ql-editor h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "DM Serif Text", serif !important;
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
|
||||
36
apps/web/src/components/editor/editormenu.js
Normal file
36
apps/web/src/components/editor/editormenu.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { useStore } from "../../stores/editor-store";
|
||||
import { Flex, Button } from "rebass";
|
||||
|
||||
function EditorMenu(props) {
|
||||
const saveSession = useStore(store => store.saveSession);
|
||||
const newSession = useStore(store => store.newSession);
|
||||
const { quill } = props;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
sx={{
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "border",
|
||||
fontSize: 13
|
||||
}}
|
||||
>
|
||||
<Button variant="menu" onClick={() => newSession()}>
|
||||
<span style={{ textDecoration: "underline" }}>N</span>ew
|
||||
</Button>
|
||||
<Button variant="menu" onClick={() => quill.history.undo()}>
|
||||
Undo
|
||||
</Button>
|
||||
<Button variant="menu" onClick={() => quill.history.redo()}>
|
||||
Redo
|
||||
</Button>
|
||||
<Button variant="menu" onClick={() => saveSession()}>
|
||||
<span style={{ textDecoration: "underline" }}>S</span>ave
|
||||
</Button>
|
||||
<Button variant="menu">
|
||||
<span style={{ textDecoration: "underline" }}>E</span>xport
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default EditorMenu;
|
||||
92
apps/web/src/components/editor/header.js
Normal file
92
apps/web/src/components/editor/header.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import "./editor.css";
|
||||
import { Flex, Text } from "rebass";
|
||||
import * as Icon from "../icons";
|
||||
import { useStore as useAppStore } from "../../stores/app-store";
|
||||
import TitleBox from "./title-box";
|
||||
import { useStore, SESSION_STATES } from "../../stores/editor-store";
|
||||
import { timeConverter } from "../../utils/time";
|
||||
import { countWords } from "../../utils/string";
|
||||
|
||||
const TextSeperator = () => {
|
||||
return (
|
||||
<Text as="span" mx={1} mt={"-3px"} fontSize="20px">
|
||||
•
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
function Header() {
|
||||
const title = useStore((store) => store.session.title);
|
||||
const dateEdited = useStore((store) => store.session.dateEdited);
|
||||
const id = useStore((store) => store.session.id);
|
||||
const text = useStore((store) => store.session.content.text);
|
||||
const isSaving = useStore((store) => store.session.isSaving);
|
||||
const sessionState = useStore((store) => store.session.state);
|
||||
const setSession = useStore((store) => store.setSession);
|
||||
const isFocusMode = useAppStore((store) => store.isFocusMode);
|
||||
const toggleFocusMode = useAppStore((store) => store.toggleFocusMode);
|
||||
const toggleProperties = useStore((store) => store.toggleProperties);
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<Flex flex="1 1 auto" flexDirection="column">
|
||||
<TitleBox
|
||||
shouldFocus={sessionState === SESSION_STATES.new}
|
||||
title={title}
|
||||
setTitle={(title) =>
|
||||
setSession((state) => {
|
||||
state.session.title = title;
|
||||
})
|
||||
}
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 0,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
fontSize={"subBody"}
|
||||
mx={2}
|
||||
color="fontTertiary"
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginTop: dateEdited || text.length || id.length ? 0 : 2,
|
||||
marginBottom: dateEdited || text.length || id.length ? 2 : 0,
|
||||
}}
|
||||
>
|
||||
{dateEdited > 0 ? (
|
||||
<>
|
||||
{timeConverter(dateEdited)}
|
||||
<TextSeperator />
|
||||
</>
|
||||
) : null}
|
||||
{text.length > 0 ? (
|
||||
<>
|
||||
{countWords(text) + " words"}
|
||||
<TextSeperator />
|
||||
</>
|
||||
) : null}
|
||||
{id && id.length > 0 ? <>{isSaving ? "Saving" : "Saved"}</> : null}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex
|
||||
alignItems="center"
|
||||
pr={3}
|
||||
onClick={() => {
|
||||
toggleFocusMode();
|
||||
}}
|
||||
>
|
||||
{isFocusMode ? (
|
||||
<Icon.NormalMode size={30} />
|
||||
) : (
|
||||
<Icon.FocusMode size={30} />
|
||||
)}
|
||||
</Flex>
|
||||
<Flex alignItems="center" onClick={() => toggleProperties()} pr={3}>
|
||||
<Icon.Properties size={30} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default Header;
|
||||
@@ -1,237 +1,78 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import "./editor.css";
|
||||
import ReactQuill, { Quill } from "react-quill";
|
||||
import "react-quill/dist/quill.bubble.css";
|
||||
import "react-quill/dist/quill.snow.css";
|
||||
import ReactQuill from "./react-quill";
|
||||
import { Flex, Box } from "rebass";
|
||||
import { Input } from "@rebass/forms";
|
||||
import MarkdownShortcuts from "./modules/markdown";
|
||||
import MagicUrl from "quill-magic-url";
|
||||
import { db, ev } from "../../common";
|
||||
import { showSnack } from "../snackbar";
|
||||
import * as Icon from "react-feather";
|
||||
import Properties from "../properties";
|
||||
import { useStore, SESSION_STATES } from "../../stores/editor-store";
|
||||
import { useStore as useAppStore } from "../../stores/app-store";
|
||||
import Animated from "../animated";
|
||||
import EditorMenu from "./editormenu";
|
||||
import Header from "./header";
|
||||
|
||||
Quill.register("modules/markdownShortcuts", MarkdownShortcuts);
|
||||
Quill.register("modules/magicUrl", MagicUrl);
|
||||
function Editor() {
|
||||
const id = useStore((store) => store.session.id);
|
||||
const delta = useStore((store) => store.session.content.delta);
|
||||
const sessionState = useStore((store) => store.session.state);
|
||||
const setSession = useStore((store) => store.setSession);
|
||||
const saveSession = useStore((store) => store.saveSession);
|
||||
const isFocusMode = useAppStore((store) => store.isFocusMode);
|
||||
const quillRef = useRef();
|
||||
|
||||
const modules = {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, 3, 4, 5, 6] }],
|
||||
["bold", "italic", "underline", "strike", "blockquote"],
|
||||
[
|
||||
{ align: "" },
|
||||
{ align: "center" },
|
||||
{ align: "right" },
|
||||
{ align: "justify" }
|
||||
],
|
||||
[
|
||||
{ list: "ordered" },
|
||||
{ list: "bullet" },
|
||||
{ indent: "-1" },
|
||||
{ indent: "+1" }
|
||||
],
|
||||
[{ size: ["small", false, "large", "huge"] }],
|
||||
["code-block", { script: "sub" }, { script: "super" }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
["link", "image", "video"],
|
||||
[{ direction: "rtl" }, "clean"]
|
||||
],
|
||||
syntax: true,
|
||||
markdownShortcuts: {},
|
||||
magicUrl: true
|
||||
};
|
||||
|
||||
export default class Editor extends React.Component {
|
||||
title = "";
|
||||
timeout = "";
|
||||
id = "";
|
||||
favorite = false;
|
||||
pinned = false;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
pinned: false,
|
||||
favorite: false,
|
||||
colors: [],
|
||||
tags: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
useEffect(() => {
|
||||
// move the toolbar outside (easiest way)
|
||||
const toolbar = document.querySelector(".ql-toolbar.ql-snow");
|
||||
const toolbarContainer = document.querySelector("#toolbar");
|
||||
if (toolbar && toolbarContainer) {
|
||||
toolbarContainer.appendChild(toolbar);
|
||||
}
|
||||
}, []);
|
||||
|
||||
this.quill = this.quillRef.getEditor();
|
||||
this.quill.keyboard.addBinding(
|
||||
{
|
||||
key: "S",
|
||||
shortKey: true
|
||||
},
|
||||
async () => {
|
||||
await this.saveNote();
|
||||
showSnack("Note saved!", Icon.Check);
|
||||
}
|
||||
);
|
||||
|
||||
ev.addListener("onNewNote", this.onNewNote.bind(this));
|
||||
ev.addListener("onOpenNote", this.onOpenNote.bind(this));
|
||||
ev.addListener("onClearNote", this.onClearNote.bind(this));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
ev.removeListener("onNewNote", this.onNewNote.bind(this));
|
||||
ev.removeListener("onOpenNote", this.onOpenNote.bind(this));
|
||||
ev.removeListener("onClearNote", this.onClearNote.bind(this));
|
||||
}
|
||||
|
||||
onNewNote(show = true, cb = null) {
|
||||
clearTimeout(this.timeout);
|
||||
this.saveNote().then(() => {
|
||||
this.titleRef.value = "";
|
||||
this.id = undefined;
|
||||
this.title = undefined;
|
||||
this.titleRef.focus();
|
||||
this.quill.setText("\n");
|
||||
cb && cb();
|
||||
if (show) {
|
||||
showSnack("Let's start writing!", Icon.Edit2);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onClearNote(id = undefined) {
|
||||
if (id && id !== this.id) return;
|
||||
this.onNewNote(false);
|
||||
}
|
||||
|
||||
onOpenNote(note) {
|
||||
if (!note) return;
|
||||
this.onNewNote(false, async () => {
|
||||
let dbNote = db.notes.note(note.id);
|
||||
if (!dbNote) return this.onNewNote(false);
|
||||
this.id = note.id;
|
||||
this.title = note.title;
|
||||
this.titleRef.value = note.title;
|
||||
this.pinned = note.pinned;
|
||||
this.favorite = note.favorite;
|
||||
this.setState({
|
||||
pinned: this.pinned,
|
||||
favorite: this.favorite,
|
||||
colors: note.colors
|
||||
});
|
||||
let delta = await dbNote.delta();
|
||||
this.quill.setContents(delta);
|
||||
this.quill.setSelection(note.content.text.length - 1, 0); //to move the cursor to the end
|
||||
});
|
||||
}
|
||||
|
||||
async saveNote() {
|
||||
let content = {
|
||||
delta: this.quill.getContents(),
|
||||
text: this.quill.getText()
|
||||
};
|
||||
if (!this.title && (!content.delta || content.text.length <= 1))
|
||||
return this.id;
|
||||
let note = {
|
||||
content,
|
||||
title: this.title,
|
||||
id: this.id,
|
||||
favorite: this.favorite,
|
||||
pinned: this.pinned,
|
||||
colors: this.state.colors
|
||||
//TODO add tags once the database is done
|
||||
};
|
||||
return await db.notes.add(note);
|
||||
}
|
||||
|
||||
save() {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(async () => {
|
||||
this.id = await this.saveNote();
|
||||
ev.emit("refreshNotes");
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Flex width={["0%", "0%", "100%"]}>
|
||||
<Flex
|
||||
className="editor"
|
||||
flex="1 1 auto"
|
||||
flexDirection="column"
|
||||
onBlur={() => {
|
||||
ev.emit("refreshNotes");
|
||||
return (
|
||||
<Animated.Flex
|
||||
width={["0%", "0%", "100%"]}
|
||||
initial={{ width: "100%" }}
|
||||
animate={{
|
||||
width: isFocusMode ? "55%" : "100%",
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: "easeIn" }}
|
||||
sx={{
|
||||
position: "relative",
|
||||
alignSelf: isFocusMode ? "center" : "stretch",
|
||||
}}
|
||||
flex="1 1 auto"
|
||||
>
|
||||
<Flex
|
||||
variant="columnFill"
|
||||
className="editor"
|
||||
onFocus={() => {
|
||||
//hideProperties();
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
<Box id="toolbar" display={["none", "flex", "flex"]} />
|
||||
<EditorMenu quill={quillRef.current && quillRef.current.quill} />
|
||||
<ReactQuill
|
||||
id="quill"
|
||||
ref={quillRef}
|
||||
refresh={sessionState === SESSION_STATES.new}
|
||||
initialContent={delta}
|
||||
placeholder="Type anything here"
|
||||
container=".editor"
|
||||
onSave={() => {
|
||||
saveSession();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={ref => (this.titleRef = ref)}
|
||||
placeholder="Untitled"
|
||||
fontFamily="heading"
|
||||
fontWeight="heading"
|
||||
fontSize="heading"
|
||||
display={["none", "flex", "flex"]}
|
||||
sx={{
|
||||
borderWidth: 0,
|
||||
":focus": { outline: "none" },
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0
|
||||
}}
|
||||
px={2}
|
||||
onChange={e => {
|
||||
this.title = e.target.value;
|
||||
this.save();
|
||||
}}
|
||||
/>
|
||||
<Box id="toolbar" display={["none", "flex", "flex"]}></Box>
|
||||
<ReactQuill
|
||||
ref={ref => (this.quillRef = ref)}
|
||||
modules={modules}
|
||||
theme="snow"
|
||||
onChange={() => {
|
||||
this.save();
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Properties
|
||||
pinned={this.state.pinned}
|
||||
favorite={this.state.favorite}
|
||||
onPinned={state => {
|
||||
this.pinned = state;
|
||||
this.save();
|
||||
onChange={(editor) => {
|
||||
setSession((state) => {
|
||||
state.session.content = {
|
||||
delta: { ops: editor.getContents().ops },
|
||||
text: editor.getText(),
|
||||
};
|
||||
});
|
||||
}}
|
||||
onFavorited={state => {
|
||||
this.favorite = state;
|
||||
this.save();
|
||||
}}
|
||||
selectedColors={this.state.colors}
|
||||
colorSelected={color => {
|
||||
let colors = [...this.state.colors];
|
||||
if (colors.includes(color.label)) {
|
||||
colors.splice(colors.indexOf(color.label), 1);
|
||||
} else {
|
||||
colors[colors.length] = color.label;
|
||||
}
|
||||
this.setState({ colors });
|
||||
}}
|
||||
tags={this.state.tags}
|
||||
addTag={tag => {
|
||||
let tags = [...this.state.tags];
|
||||
if (tags.includes(tag)) {
|
||||
tags.splice(tags.indexOf(tag), 1);
|
||||
} else {
|
||||
tags[this.state.tags.length] = tag;
|
||||
}
|
||||
this.setState({ tags });
|
||||
}}
|
||||
onLocked={state => {}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
{id ? <Properties /> : null}
|
||||
</Animated.Flex>
|
||||
);
|
||||
}
|
||||
export default Editor;
|
||||
|
||||
@@ -50,7 +50,7 @@ class MarkdownShortcuts {
|
||||
this.quill.formatLine(selection.index, 0, "header", size - 1);
|
||||
this.quill.deleteText(selection.index - size, size);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
@@ -61,7 +61,7 @@ class MarkdownShortcuts {
|
||||
this.quill.formatLine(selection.index, 1, "blockquote", true);
|
||||
this.quill.deleteText(selection.index - 2, 2);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "code-block",
|
||||
@@ -72,7 +72,7 @@ class MarkdownShortcuts {
|
||||
this.quill.formatLine(selection.index, 1, "code-block", true);
|
||||
this.quill.deleteText(selection.index - 4, 4);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bolditalic",
|
||||
@@ -90,11 +90,11 @@ class MarkdownShortcuts {
|
||||
this.quill.deleteText(startIndex, annotatedText.length);
|
||||
this.quill.insertText(startIndex, matchedText, {
|
||||
bold: true,
|
||||
italic: true
|
||||
italic: true,
|
||||
});
|
||||
this.quill.format("bold", false);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bold",
|
||||
@@ -113,7 +113,7 @@ class MarkdownShortcuts {
|
||||
this.quill.insertText(startIndex, matchedText, { bold: true });
|
||||
this.quill.format("bold", false);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "italic",
|
||||
@@ -132,7 +132,7 @@ class MarkdownShortcuts {
|
||||
this.quill.insertText(startIndex, matchedText, { italic: true });
|
||||
this.quill.format("italic", false);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
@@ -151,7 +151,7 @@ class MarkdownShortcuts {
|
||||
this.quill.insertText(startIndex, matchedText, { strike: true });
|
||||
this.quill.format("strike", false);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "code",
|
||||
@@ -171,7 +171,7 @@ class MarkdownShortcuts {
|
||||
this.quill.format("code", false);
|
||||
this.quill.insertText(this.quill.getSelection(), " ");
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hr",
|
||||
@@ -190,7 +190,7 @@ class MarkdownShortcuts {
|
||||
this.quill.insertText(startIndex + 2, "\n", Quill.sources.SILENT);
|
||||
this.quill.setSelection(startIndex + 2, Quill.sources.SILENT);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "asterisk-ul",
|
||||
@@ -199,14 +199,13 @@ class MarkdownShortcuts {
|
||||
setTimeout(() => {
|
||||
let index = selection.index;
|
||||
this.quill.formatLine(index, 1, "list", "unordered");
|
||||
console.log(text, selection, pattern);
|
||||
if (text.trim() === "*") {
|
||||
this.quill.deleteText(index, 1);
|
||||
} else if (text.trim() === "+") {
|
||||
this.quill.deleteText(index - 2, 2);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
@@ -227,7 +226,7 @@ class MarkdownShortcuts {
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "link",
|
||||
@@ -249,8 +248,8 @@ class MarkdownShortcuts {
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Handler that looks for insert deltas that match specific characters
|
||||
|
||||
113
apps/web/src/components/editor/react-quill.js
vendored
Normal file
113
apps/web/src/components/editor/react-quill.js
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
import React, { Component } from "react";
|
||||
import Quill from "quill";
|
||||
import "quill/dist/quill.snow.css";
|
||||
import "quill/dist/quill.core.css";
|
||||
import MarkdownShortcuts from "./modules/markdown";
|
||||
import MagicUrl from "quill-magic-url";
|
||||
|
||||
Quill.register("modules/markdownShortcuts", MarkdownShortcuts);
|
||||
Quill.register("modules/magicUrl", MagicUrl);
|
||||
|
||||
const quillModules = {
|
||||
toolbar: [
|
||||
[{ header: [false, 1, 2, 3, 4, 5, 6] }],
|
||||
["bold", "italic", "underline", "strike", "blockquote"],
|
||||
[
|
||||
{ align: "" },
|
||||
{ align: "center" },
|
||||
{ align: "right" },
|
||||
{ align: "justify" },
|
||||
],
|
||||
[
|
||||
{ list: "ordered" },
|
||||
{ list: "bullet" },
|
||||
{ indent: "-1" },
|
||||
{ indent: "+1" },
|
||||
],
|
||||
[{ size: ["small", false, "large", "huge"] }],
|
||||
["code-block", { script: "sub" }, { script: "super" }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
["link", "image", "video"],
|
||||
[{ direction: "rtl" }, "clean"],
|
||||
],
|
||||
// syntax: true,
|
||||
markdownShortcuts: {},
|
||||
magicUrl: true,
|
||||
};
|
||||
|
||||
export default class ReactQuill extends Component {
|
||||
/** @private */
|
||||
quill;
|
||||
getEditor() {
|
||||
return this.quill.editor;
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return (
|
||||
this.props.readOnly !== nextProps.readOnly || nextProps.refresh || false
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const { initialContent, refresh } = this.props;
|
||||
if (refresh) {
|
||||
this.quill.setContents(initialContent);
|
||||
if (!initialContent.ops || !initialContent.ops.length) return;
|
||||
const text = this.quill.getText();
|
||||
this.quill.setSelection(text.length, 0);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
placeholder,
|
||||
container,
|
||||
readOnly,
|
||||
initialContent,
|
||||
onChange,
|
||||
onSave,
|
||||
modules,
|
||||
id,
|
||||
} = this.props;
|
||||
|
||||
this.quill = new Quill("#" + id, {
|
||||
placeholder,
|
||||
bounds: container,
|
||||
modules: modules || quillModules,
|
||||
theme: "snow",
|
||||
readOnly,
|
||||
});
|
||||
|
||||
if (initialContent) {
|
||||
this.quill.setContents(initialContent);
|
||||
}
|
||||
|
||||
if (onChange) {
|
||||
this.quill.on("text-change", this.textChangeHandler);
|
||||
}
|
||||
|
||||
if (onSave) {
|
||||
this.quill.keyboard.addBinding(
|
||||
{
|
||||
key: "S",
|
||||
shortKey: true,
|
||||
},
|
||||
onSave.bind(this, this.quill)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (!this.quill) return;
|
||||
this.quill.off("text-change", this.textChangeHandler);
|
||||
}
|
||||
|
||||
textChangeHandler = (delta, oldDelta, source) => {
|
||||
if (source !== "user") return;
|
||||
this.props.onChange(this.quill);
|
||||
};
|
||||
|
||||
render() {
|
||||
return <pre id={this.props.id} />;
|
||||
}
|
||||
}
|
||||
43
apps/web/src/components/editor/titlebox.js
Normal file
43
apps/web/src/components/editor/titlebox.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import "./editor.css";
|
||||
import { Input } from "@rebass/forms";
|
||||
|
||||
class TitleBox extends React.Component {
|
||||
inputRef;
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return nextProps.title !== this.props.title || nextProps.shouldFocus;
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.props.shouldFocus) {
|
||||
this.inputRef.focus();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { title, setTitle, sx } = this.props;
|
||||
return (
|
||||
<Input
|
||||
ref={(ref) => (this.inputRef = ref)}
|
||||
maxLength={120}
|
||||
placeholder="Untitled"
|
||||
fontFamily="heading"
|
||||
fontWeight="heading"
|
||||
fontSize="heading"
|
||||
color="text"
|
||||
display={["none", "flex", "flex"]}
|
||||
px={2}
|
||||
sx={{
|
||||
borderWidth: 0,
|
||||
":focus": { outline: "none" },
|
||||
...sx,
|
||||
}}
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default TitleBox;
|
||||
@@ -1,17 +1,77 @@
|
||||
import React from "react";
|
||||
import MDIIcon from "@mdi/react";
|
||||
import * as Icons from "@mdi/js";
|
||||
import { useTheme } from "emotion-theming";
|
||||
import Animated from "../animated";
|
||||
|
||||
export const PinIcon = ({ size }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 24 24`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M17.85 10.2l-4.24 5.65m4.24-5.66L13.6 5.95m4.24 4.24a2 2 0 0 0 2.83 0l.7-.7-7.07-7.08-.7.71a2 2 0 0 0 0 2.83m0 9.9l-5.66-5.66m5.66 5.66s1.76 2.47-.71 4.95L3 10.9c2.47-2.48 4.95-.7 4.95-.7m0 0l5.66-4.25M7.95 15.85l-4.24 4.24" />
|
||||
</svg>
|
||||
);
|
||||
function Icon({ name, size = 24, color = "icon", stroke, rotate }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<MDIIcon
|
||||
path={name}
|
||||
size={size + "px"}
|
||||
stroke={stroke}
|
||||
color={theme.colors[color] || color}
|
||||
spin={rotate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function createIcon(name) {
|
||||
return function (props) {
|
||||
return (
|
||||
<Animated.Box
|
||||
sx={props.sx}
|
||||
height={(props.size || 24) + "px"}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
animate={props.animation}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Icon name={name} {...props} />
|
||||
</Animated.Box>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const Plus = createIcon(Icons.mdiPlus);
|
||||
export const Minus = createIcon(Icons.mdiMinus);
|
||||
export const Notebook = createIcon(Icons.mdiBookOutline);
|
||||
export const ArrowLeft = createIcon(Icons.mdiArrowLeft);
|
||||
export const Move = createIcon(Icons.mdiArrowAll);
|
||||
export const Topic = createIcon(Icons.mdiFormatTitle);
|
||||
export const Alert = createIcon(Icons.mdiAlert);
|
||||
export const Vault = createIcon(Icons.mdiShieldOutline);
|
||||
export const Unlock = createIcon(Icons.mdiLockOpenOutline);
|
||||
export const Lock = createIcon(Icons.mdiLock);
|
||||
export const Star = createIcon(Icons.mdiStar);
|
||||
export const StarOutline = createIcon(Icons.mdiStarOutline);
|
||||
export const Circle = createIcon(Icons.mdiCircle);
|
||||
export const CircleEmpty = createIcon(Icons.mdiCircleOutline);
|
||||
export const Check = createIcon(Icons.mdiCheckCircleOutline);
|
||||
export const MoreVertical = createIcon(Icons.mdiDotsVertical);
|
||||
export const Trash = createIcon(Icons.mdiTrashCanOutline);
|
||||
export const Search = createIcon(Icons.mdiMagnify);
|
||||
export const Menu = createIcon(Icons.mdiHamburger);
|
||||
export const Login = createIcon(Icons.mdiLogin);
|
||||
export const FocusMode = createIcon(Icons.mdiFullscreen);
|
||||
export const NormalMode = createIcon(Icons.mdiFullscreenExit);
|
||||
export const Settings = createIcon(Icons.mdiCogOutline);
|
||||
export const Home = createIcon(Icons.mdiHomeOutline);
|
||||
export const Restore = createIcon(Icons.mdiRecycle);
|
||||
export const Sync = createIcon(Icons.mdiSync);
|
||||
export const Loading = createIcon(Icons.mdiLoading);
|
||||
|
||||
/** Properties Icons */
|
||||
export const ChevronLeft = createIcon(Icons.mdiChevronLeft);
|
||||
export const Close = createIcon(Icons.mdiClose);
|
||||
export const Tag = createIcon(Icons.mdiTagOutline);
|
||||
export const Pin = createIcon(Icons.mdiPinOutline);
|
||||
export const PinFilled = createIcon(Icons.mdiPin);
|
||||
|
||||
/** Settings Icons */
|
||||
export const User = createIcon(Icons.mdiAccountOutline);
|
||||
export const Theme = createIcon(Icons.mdiThemeLightDark);
|
||||
export const Checkmark = createIcon(Icons.mdiCheck);
|
||||
|
||||
export const Properties = createIcon(Icons.mdiDotsHorizontal);
|
||||
|
||||
17
apps/web/src/components/inputs/email.js
Normal file
17
apps/web/src/components/inputs/email.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
import Input from "./index";
|
||||
import { isValidEmail } from "../../utils/validation";
|
||||
|
||||
function EmailInput(props) {
|
||||
return (
|
||||
<Input
|
||||
title="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
error="Email is not valid."
|
||||
validate={isValidEmail}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default EmailInput;
|
||||
42
apps/web/src/components/inputs/index.js
Normal file
42
apps/web/src/components/inputs/index.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useState } from "react";
|
||||
import { Input as RebassInput } from "@rebass/forms";
|
||||
import { Box, Text } from "rebass";
|
||||
import { toTitleCase } from "../../utils/string";
|
||||
|
||||
function Input(props) {
|
||||
const { required = true, validate, variant = "input" } = props;
|
||||
const { name, form, title } = props;
|
||||
const [themeVariant, setThemeVariant] = useState(variant);
|
||||
const [error, setError] = useState(props.error);
|
||||
return (
|
||||
<Box>
|
||||
<RebassInput
|
||||
{...props}
|
||||
variant={themeVariant}
|
||||
placeholder={title}
|
||||
onChange={event => {
|
||||
const { value } = event.target;
|
||||
const isValid = !validate || validate(value, form);
|
||||
if ((!value.trim() && required) || !isValid) {
|
||||
setThemeVariant("error");
|
||||
if (form) form.error = true;
|
||||
const error =
|
||||
!isValid || !name
|
||||
? props.error
|
||||
: `${toTitleCase(name)} is required.`;
|
||||
setError(error);
|
||||
} else {
|
||||
setThemeVariant(variant);
|
||||
if (form) {
|
||||
form[name] = value;
|
||||
form.error = false;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{themeVariant === "error" && <Text variant="error">{error}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Input;
|
||||
25
apps/web/src/components/inputs/password.js
Normal file
25
apps/web/src/components/inputs/password.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import Input from "./index";
|
||||
import Dropper from "../dropper";
|
||||
|
||||
function PasswordInput(props) {
|
||||
const { confirm } = props;
|
||||
return (
|
||||
<Dropper {...props}>
|
||||
<Input title="Password" type="password" name="password" />
|
||||
{confirm && (
|
||||
<Input
|
||||
title="Confirm Password"
|
||||
type="password"
|
||||
error="Passwords do not match"
|
||||
name="confirm"
|
||||
validate={(password, form) =>
|
||||
!form.password || form.password === password
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Dropper>
|
||||
);
|
||||
}
|
||||
|
||||
export default PasswordInput;
|
||||
@@ -1,24 +1,59 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { Flex } from "rebass";
|
||||
import Button from "../button";
|
||||
import Search from "../search";
|
||||
import * as Icon from "react-feather";
|
||||
import * as Icon from "../icons";
|
||||
import { Virtuoso as List } from "react-virtuoso";
|
||||
import { useStore as useSearchStore } from "../../stores/searchstore";
|
||||
import { useStore as useSelectionStore } from "../../stores/selection-store";
|
||||
|
||||
function ListContainer(props) {
|
||||
const setSearchContext = useSearchStore((store) => store.setSearchContext);
|
||||
const shouldSelectAll = useSelectionStore((store) => store.shouldSelectAll);
|
||||
const setSelectedItems = useSelectionStore((store) => store.setSelectedItems);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldSelectAll) setSelectedItems(props.items);
|
||||
}, [shouldSelectAll, setSelectedItems, props.items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.noSearch) return;
|
||||
setSearchContext({
|
||||
items: props.items,
|
||||
item: props.item,
|
||||
type: props.type,
|
||||
});
|
||||
}, [setSearchContext, props.item, props.items, props.type, props.noSearch]);
|
||||
|
||||
const ListContainer = props => {
|
||||
return (
|
||||
<Flex flexDirection="column" flex="1 1 auto">
|
||||
<Search placeholder="Search" />
|
||||
<List
|
||||
style={{
|
||||
width: "100%",
|
||||
flex: "1 1 auto",
|
||||
height: "auto",
|
||||
overflowX: "hidden"
|
||||
}}
|
||||
totalCount={props.itemsLength}
|
||||
item={props.item}
|
||||
/>
|
||||
<Flex variant="columnFill">
|
||||
{!props.items.length && props.placeholder ? (
|
||||
<Flex variant="columnCenterFill">
|
||||
<props.placeholder />
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
{!props.noSearch && <Search type={props.type} />}
|
||||
<Flex variant="columnFill" mt={2}>
|
||||
{props.children || props.items.length > 0 ? (
|
||||
<List
|
||||
style={{
|
||||
width: "100%",
|
||||
flex: "1 1 auto",
|
||||
height: "auto",
|
||||
overflowX: "hidden",
|
||||
}}
|
||||
totalCount={props.items.length}
|
||||
item={(index) => {
|
||||
const item = props.items[index];
|
||||
if (!item) return null;
|
||||
return props.item(index, item);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Flex>
|
||||
</>
|
||||
)}
|
||||
{props.button && (
|
||||
<Button
|
||||
Icon={props.button.icon || Icon.Plus}
|
||||
@@ -28,6 +63,5 @@ const ListContainer = props => {
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
export default ListContainer;
|
||||
|
||||
@@ -1,121 +1,213 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Flex, Box, Text } from "rebass";
|
||||
import * as Icon from "react-feather";
|
||||
import Dropdown, { DropdownTrigger, DropdownContent } from "../dropdown";
|
||||
import * as Icon from "../icons";
|
||||
import Menu from "../menu";
|
||||
import {
|
||||
store as selectionStore,
|
||||
useStore as useSelectionStore,
|
||||
} from "../../stores/selection-store";
|
||||
import useContextMenu from "../../utils/useContextMenu";
|
||||
|
||||
const ListItem = props => (
|
||||
<Flex
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
py={1}
|
||||
bg={props.pinned ? "shade" : "background"}
|
||||
px={2}
|
||||
sx={{
|
||||
position: "relative",
|
||||
marginTop: props.pinned ? 4 : 0,
|
||||
borderBottom: "1px solid",
|
||||
borderBottomColor: "navbg",
|
||||
cursor: "default",
|
||||
":hover": {
|
||||
borderBottomColor: "primary",
|
||||
cursor: "pointer"
|
||||
function selectMenuItem(isSelected, toggleSelection) {
|
||||
return {
|
||||
title: isSelected ? "Unselect" : "Select",
|
||||
onClick: () => {
|
||||
const selectionState = selectionStore.get();
|
||||
if (!selectionState.isSelectionMode) {
|
||||
selectionState.toggleSelectionMode();
|
||||
toggleSelection();
|
||||
} else {
|
||||
toggleSelection();
|
||||
}
|
||||
//TODO add onpressed reaction
|
||||
}}
|
||||
>
|
||||
{props.pinned && (
|
||||
<Flex
|
||||
bg="primary"
|
||||
sx={{
|
||||
borderRadius: 35,
|
||||
width: 30,
|
||||
height: 30,
|
||||
position: "absolute",
|
||||
top: -15,
|
||||
left: 0,
|
||||
marginTop: 5,
|
||||
boxShadow: "2px 1px 3px #00000066"
|
||||
}}
|
||||
mx={2}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box
|
||||
bg="static"
|
||||
sx={{
|
||||
borderRadius: 5,
|
||||
width: 5,
|
||||
height: 5
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<Box
|
||||
onClick={() => {
|
||||
//e.stopPropagation();
|
||||
if (props.onClick) {
|
||||
props.onClick();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
flex: "1 1 auto",
|
||||
paddingTop: props.pinned ? 4 : 0,
|
||||
":hover": {
|
||||
cursor: "pointer"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Flex flexDirection="row" justifyContent="space-between">
|
||||
<Text fontFamily="heading" fontSize="title" fontWeight="bold">
|
||||
{props.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Text
|
||||
display={props.body ? "flex" : "none"}
|
||||
variant="body"
|
||||
sx={{
|
||||
marginBottom: 1,
|
||||
":hover": {
|
||||
cursor: "pointer"
|
||||
},
|
||||
flexWrap: "wrap"
|
||||
}}
|
||||
>
|
||||
{props.body}
|
||||
</Text>
|
||||
{props.subBody && props.subBody}
|
||||
<Text
|
||||
display={props.info ? "flex" : "none"}
|
||||
variant="body"
|
||||
fontSize={10}
|
||||
color="fontTertiary"
|
||||
>
|
||||
{props.info}
|
||||
</Text>
|
||||
</Box>
|
||||
<Dropdown
|
||||
style={{ zIndex: 1, marginRight: -4 }}
|
||||
ref={ref => (props.dropdownRefs[props.index] = ref)}
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<Text sx={{ ":active, :hover": { color: "primary" } }}>
|
||||
<Icon.MoreVertical
|
||||
size={24}
|
||||
strokeWidth={1.5}
|
||||
style={{ marginRight: -5 }}
|
||||
/>
|
||||
</Text>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent style={{ zIndex: 2, marginLeft: -110 }}>
|
||||
<Menu
|
||||
dropdownRef={props.dropdownRefs[props.index]}
|
||||
menuItems={props.menuItems}
|
||||
data={props.menuData}
|
||||
/>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ItemSelector = ({ isSelected, toggleSelection }) => {
|
||||
return (
|
||||
<Box
|
||||
width={24}
|
||||
sx={{
|
||||
marginLeft: 3,
|
||||
marginRight: 1,
|
||||
color: "primary",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => toggleSelection()}
|
||||
>
|
||||
{isSelected ? <Icon.Check /> : <Icon.CircleEmpty />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
function ListItem(props) {
|
||||
const menuId = `contextMenu${props.index}`;
|
||||
const [parentRef, closeMenu, openMenu] = useContextMenu(menuId);
|
||||
const isSelectionMode = useSelectionStore((store) => store.isSelectionMode);
|
||||
const selectedItems = useSelectionStore((store) => store.selectedItems);
|
||||
const isSelected =
|
||||
selectedItems.findIndex((item) => props.item.id === item.id) > -1;
|
||||
const selectItem = useSelectionStore((store) => store.selectItem);
|
||||
const [menuItems, setMenuItems] = useState(props.menuItems);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
function toggleSelection() {
|
||||
selectItem(props.item);
|
||||
},
|
||||
[selectItem, props.item]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSelectionMode && isSelected) toggleSelection();
|
||||
}, [isSelectionMode, toggleSelection, isSelected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.selectable) {
|
||||
setMenuItems([
|
||||
selectMenuItem(isSelected, toggleSelection),
|
||||
...props.menuItems,
|
||||
]);
|
||||
}
|
||||
}, [props.menuItems, isSelected, props.selectable, toggleSelection]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={parentRef}
|
||||
bg={props.pinned ? "shade" : "background"}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
borderBottom: "1px solid",
|
||||
borderBottomColor: "border",
|
||||
cursor: "pointer",
|
||||
":hover": {
|
||||
borderBottomColor: "primary",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isSelectionMode && (
|
||||
<ItemSelector
|
||||
isSelected={isSelected}
|
||||
toggleSelection={toggleSelection}
|
||||
/>
|
||||
)}
|
||||
<Flex
|
||||
flex="1 1 auto"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
px={2}
|
||||
sx={{
|
||||
width: "full",
|
||||
position: "relative",
|
||||
marginTop: props.pinned ? 4 : 0,
|
||||
paddingTop: props.pinned ? 0 : 2,
|
||||
paddingBottom: 2,
|
||||
|
||||
//TODO add onpressed reaction
|
||||
}}
|
||||
>
|
||||
{props.pinned && (
|
||||
<Flex
|
||||
variant="rowCenter"
|
||||
bg="primary"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: -15,
|
||||
left: 0,
|
||||
borderRadius: 35,
|
||||
width: 30,
|
||||
height: 30,
|
||||
boxShadow: "2px 1px 3px #00000066",
|
||||
}}
|
||||
mx={2}
|
||||
>
|
||||
<Box
|
||||
bg="static"
|
||||
sx={{
|
||||
borderRadius: 5,
|
||||
width: 5,
|
||||
height: 5,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<Box
|
||||
onClick={() => {
|
||||
//e.stopPropagation();
|
||||
if (isSelectionMode) {
|
||||
toggleSelection();
|
||||
} else if (props.onClick) {
|
||||
props.onClick();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
flex: "1 1 auto",
|
||||
paddingTop: props.pinned ? 4 : 0,
|
||||
":hover": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
color={props.focused ? "primary" : "text"}
|
||||
fontFamily={"heading"}
|
||||
fontSize="title"
|
||||
fontWeight={"bold"}
|
||||
>
|
||||
{props.title}
|
||||
</Text>
|
||||
<Text
|
||||
display={props.body ? "flex" : "none"}
|
||||
variant="body"
|
||||
sx={{
|
||||
marginBottom: 1,
|
||||
":hover": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
{props.body}
|
||||
</Text>
|
||||
{props.subBody && props.subBody}
|
||||
<Text
|
||||
display={props.info ? "flex" : "none"}
|
||||
variant="body"
|
||||
fontSize={11}
|
||||
color="fontTertiary"
|
||||
sx={{ marginTop: 2 }}
|
||||
>
|
||||
{props.info}
|
||||
</Text>
|
||||
</Box>
|
||||
{props.menuItems && (
|
||||
<Icon.MoreVertical
|
||||
size={22}
|
||||
strokeWidth={2}
|
||||
color="icon"
|
||||
style={{ marginRight: -5 }}
|
||||
onClick={(e) => {
|
||||
openMenu(e.nativeEvent, menuId, true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{props.menuItems &&
|
||||
ReactDOM.createPortal(
|
||||
<Menu
|
||||
id={menuId}
|
||||
menuItems={menuItems}
|
||||
data={props.menuData}
|
||||
style={{
|
||||
position: "absolute",
|
||||
display: "none",
|
||||
zIndex: 999,
|
||||
}}
|
||||
closeMenu={() => closeMenu()}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default ListItem;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Flex, Text } from "rebass";
|
||||
import Button from "../button";
|
||||
import { ev } from "../../common";
|
||||
import ListItem from "../list-item";
|
||||
import TimeAgo from "timeago-react";
|
||||
import ListContainer from "../list-container";
|
||||
|
||||
function ListView({ type, getItems, menu, button, onClick }) {
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
function onRefreshItems() {
|
||||
setItems(getItems);
|
||||
}
|
||||
onRefreshItems();
|
||||
ev.addListener(`refresh${type}`, onRefreshItems);
|
||||
return () => {
|
||||
ev.removeListener(`refresh${type}`, onRefreshItems);
|
||||
};
|
||||
}, [getItems, type]);
|
||||
return (
|
||||
items && (
|
||||
<ListContainer
|
||||
itemsLength={items.length}
|
||||
item={index => {
|
||||
const item = items[index];
|
||||
return (
|
||||
<ListItem
|
||||
title={item.title}
|
||||
body={item.headline}
|
||||
index={index}
|
||||
onClick={onClick.bind(this, item)} //TODO
|
||||
info={
|
||||
<Flex justifyContent="center" alignItems="center">
|
||||
<TimeAgo datetime={item.dateDeleted || item.dateCreated} />
|
||||
<Text as="span" mx={1}>
|
||||
•
|
||||
</Text>
|
||||
<Text color="primary">
|
||||
{item.type[0].toUpperCase() + item.type.substring(1)}
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
menuData={item}
|
||||
menuItems={menu.menuItems(item)}
|
||||
dropdownRefs={menu.dropdownRefs}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
button={button}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default ListView;
|
||||
43
apps/web/src/components/menu/colors.js
Normal file
43
apps/web/src/components/menu/colors.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { COLORS } from "../../common";
|
||||
import * as Icon from "../icons/index";
|
||||
import { objectMap } from "../../utils/object";
|
||||
import { useStore } from "../../stores/note-store";
|
||||
import { Flex } from "rebass";
|
||||
|
||||
function Colors(props) {
|
||||
const { id, colors } = props.data;
|
||||
const setColor = useStore((store) => store.setColor);
|
||||
|
||||
return (
|
||||
<Flex flexWrap="wrap">
|
||||
{objectMap(COLORS, (label, code) => (
|
||||
<Flex
|
||||
sx={{ position: "relative" }}
|
||||
onClick={() => setColor(id, label)}
|
||||
key={label}
|
||||
>
|
||||
<Icon.Circle
|
||||
size={25}
|
||||
style={{ cursor: "pointer" }}
|
||||
color={code}
|
||||
strokeWidth={0}
|
||||
/>
|
||||
{colors.includes(label) && (
|
||||
<Icon.Checkmark
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
left: "5px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
color="static"
|
||||
size={14}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default Colors;
|
||||
@@ -4,44 +4,66 @@ import { Flex, Box, Text } from "rebass";
|
||||
function Menu(props) {
|
||||
return (
|
||||
<Flex
|
||||
id={props.id}
|
||||
bg="background"
|
||||
py={1}
|
||||
style={props.style}
|
||||
sx={{
|
||||
position: "relative",
|
||||
borderRadius: "default",
|
||||
boxShadow: 2,
|
||||
width: 120
|
||||
border: "2px solid",
|
||||
borderColor: "border",
|
||||
width: 180,
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
<Box width="100%">
|
||||
{props.menuItems.map(item => (
|
||||
<Flex
|
||||
key={item.title}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (props.dropdownRef) {
|
||||
props.dropdownRef.hide();
|
||||
}
|
||||
if (item.onClick) {
|
||||
item.onClick(props.data);
|
||||
}
|
||||
}}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
py={1}
|
||||
px={2}
|
||||
sx={{
|
||||
color: item.color || "fontPrimary",
|
||||
":hover": {
|
||||
backgroundColor: "shade"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text as="span" mx={1} fontFamily="body" fontSize="menu">
|
||||
{item.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
<Text
|
||||
fontFamily="body"
|
||||
fontSize="body"
|
||||
color="primary"
|
||||
py={"8px"}
|
||||
px={3}
|
||||
sx={{ borderBottom: "1px solid", borderBottomColor: "border" }}
|
||||
>
|
||||
Properties
|
||||
</Text>
|
||||
{props.menuItems.map(
|
||||
(item) =>
|
||||
!item.visible && (
|
||||
<Flex
|
||||
key={item.title}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (props.closeMenu) {
|
||||
props.closeMenu();
|
||||
}
|
||||
if (item.onClick) {
|
||||
item.onClick(props.data);
|
||||
}
|
||||
}}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
py={"8px"}
|
||||
px={3}
|
||||
sx={{
|
||||
color: item.color || "text",
|
||||
cursor: "pointer",
|
||||
":hover": {
|
||||
backgroundColor: "shade",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item.component ? (
|
||||
<item.component data={props.data} />
|
||||
) : (
|
||||
<Text as="span" fontFamily="body" fontSize="menu">
|
||||
{item.title}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
107
apps/web/src/components/navigation-menu/index.js
Normal file
107
apps/web/src/components/navigation-menu/index.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import { Box } from "rebass";
|
||||
import RootNavigator, {
|
||||
bottomRoutes,
|
||||
routes,
|
||||
} from "../../navigation/navigators/rootnavigator";
|
||||
import { usePersistentState } from "../../utils/hooks";
|
||||
import { useStore } from "../../stores/app-store";
|
||||
import { COLORS } from "../../common";
|
||||
import { toTitleCase } from "../../utils/string";
|
||||
import * as Icon from "../icons";
|
||||
import { useStore as useAppStore } from "../../stores/app-store";
|
||||
import Animated from "../animated";
|
||||
import NavItem from "../navitem";
|
||||
import { objectMap } from "../../utils/object";
|
||||
|
||||
function NavigationMenu(props) {
|
||||
const { toggleNavigationContainer } = props;
|
||||
const [selectedRoute, setSelectedRoute] = usePersistentState("route", "home");
|
||||
const isFocusMode = useAppStore((store) => store.isFocusMode);
|
||||
const colors = useStore((store) => store.colors);
|
||||
const isSideMenuOpen = useStore((store) => store.isSideMenuOpen);
|
||||
|
||||
return (
|
||||
<Animated.Flex
|
||||
flexDirection="column"
|
||||
justifyContent="space-between"
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{
|
||||
opacity: isFocusMode ? 0 : 1,
|
||||
visibility: isFocusMode ? "hidden" : "visible",
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
sx={{
|
||||
borderRight: "1px solid",
|
||||
borderRightColor: "border",
|
||||
minWidth: ["85%", 50, 50],
|
||||
maxWidth: ["85%", 50, 50],
|
||||
display: [isSideMenuOpen ? "flex" : "none", "flex", "flex"],
|
||||
position: ["absolute", "relative", "relative"],
|
||||
}}
|
||||
bg={"background"}
|
||||
px={0}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
overflow: "scroll",
|
||||
scrollbarWidth: "none",
|
||||
"::-webkit-scrollbar": { width: 0, height: 0 },
|
||||
msOverflowStyle: "none",
|
||||
}}
|
||||
>
|
||||
{objectMap(routes, (_, item) => (
|
||||
<NavItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
selected={selectedRoute === item.key}
|
||||
onSelected={() => {
|
||||
if (selectedRoute === item.key) toggleNavigationContainer();
|
||||
else if (RootNavigator.navigate(item.key))
|
||||
setSelectedRoute(item.key);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{colors.map((color) => {
|
||||
return (
|
||||
<NavItem
|
||||
onSelected={async () => {
|
||||
setSelectedRoute(undefined);
|
||||
RootNavigator.navigate("color", {
|
||||
title: toTitleCase(color.title),
|
||||
context: {
|
||||
type: "color",
|
||||
value: color.title,
|
||||
},
|
||||
});
|
||||
}}
|
||||
key={color.title}
|
||||
item={{
|
||||
color: COLORS[color.title],
|
||||
title: toTitleCase(color.title),
|
||||
icon: Icon.Circle,
|
||||
count: color.noteIds.length,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box>
|
||||
{Object.values(bottomRoutes).map((item) => (
|
||||
<NavItem
|
||||
onSelected={async () => {
|
||||
const shouldSelect = item.onClick
|
||||
? await item.onClick()
|
||||
: item.component && RootNavigator.navigate(item.key);
|
||||
if (shouldSelect) setSelectedRoute(item.key);
|
||||
}}
|
||||
key={item.key}
|
||||
item={item}
|
||||
selected={selectedRoute === item.key}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Animated.Flex>
|
||||
);
|
||||
}
|
||||
export default NavigationMenu;
|
||||
39
apps/web/src/components/navitem/index.js
Normal file
39
apps/web/src/components/navitem/index.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Flex, Button, Text } from "rebass";
|
||||
import { useStore as useUserStore } from "../../stores/user-store";
|
||||
|
||||
function NavItem(props) {
|
||||
const { key, animatable, icon: Icon, color, title, count } = props.item;
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isSyncing = useUserStore((store) => store.isSyncing);
|
||||
|
||||
useEffect(() => {
|
||||
if (animatable) {
|
||||
if (key === "sync") setIsLoading(isSyncing);
|
||||
}
|
||||
}, [isSyncing, setIsLoading, animatable, key]);
|
||||
|
||||
return (
|
||||
<Button variant="icon" py={3} title={title} onClick={props.onSelected}>
|
||||
<Flex
|
||||
justifyContent={["flex-start", "center", "center"]}
|
||||
alignItems="center"
|
||||
ml={[2, 0, 0]}
|
||||
sx={{ position: "relative" }}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
color={props.selected ? "primary" : color || "icon"}
|
||||
rotate={isLoading}
|
||||
/>
|
||||
<Text variant="body" display={["flex", "none", "none"]} ml={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text sx={{ position: "absolute", top: -2, right: 0 }} fontSize={9}>
|
||||
{count > 99 ? "99+" : count}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export default NavItem;
|
||||
@@ -1,100 +1,175 @@
|
||||
import React from "react";
|
||||
import { Flex } from "rebass";
|
||||
import * as Icon from "react-feather";
|
||||
import { Flex, Box, Text } from "rebass";
|
||||
import * as Icon from "../icons";
|
||||
import TimeAgo from "timeago-react";
|
||||
import { db, ev } from "../../common";
|
||||
import { showSnack } from "../snackbar";
|
||||
import ListItem from "../list-item";
|
||||
import { ask, moveNote } from "../dialogs";
|
||||
import { confirm } from "../dialogs/confirm";
|
||||
import { showMoveNoteDialog } from "../dialogs/movenotedialog";
|
||||
import { store, useStore } from "../../stores/note-store";
|
||||
import { store as editorStore } from "../../stores/editor-store";
|
||||
import { showPasswordDialog } from "../dialogs/passworddialog";
|
||||
import { db, COLORS } from "../../common";
|
||||
import { useTheme } from "emotion-theming";
|
||||
import Colors from "../menu/colors";
|
||||
|
||||
const dropdownRefs = [];
|
||||
const menuItems = note => [
|
||||
{
|
||||
title: note.notebook ? "Move" : "Add to",
|
||||
onClick: async () => {
|
||||
console.log(note.id, note.notebook);
|
||||
if (await moveNote(note.id, note.notebook)) {
|
||||
showSnack("Note moved successfully!");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: note.pinned ? "Unpin" : "Pin",
|
||||
onClick: async () =>
|
||||
db.notes
|
||||
.note(note.id)
|
||||
.pin()
|
||||
.then(() => {
|
||||
showSnack("Note pinned!", Icon.Check);
|
||||
ev.emit("refreshNotes");
|
||||
})
|
||||
},
|
||||
{
|
||||
title: note.favorite ? "Unfavorite" : "Favorite",
|
||||
onClick: async () =>
|
||||
db.notes
|
||||
.note(note.id)
|
||||
.favorite()
|
||||
.then(() => {
|
||||
showSnack("Note favorited!", Icon.Check);
|
||||
ev.emit("refreshNotes");
|
||||
})
|
||||
},
|
||||
{ title: "Edit" },
|
||||
{ title: note.locked ? "Remove lock" : "Lock" }, //TODO
|
||||
{ title: "Share" },
|
||||
{
|
||||
title: "Move to Trash",
|
||||
color: "red",
|
||||
onClick: () => {
|
||||
ask(
|
||||
Icon.Trash2,
|
||||
"Delete",
|
||||
"Are you sure you want to move this note to Trash? It will be moved to Trash and permanently deleted after 7 days."
|
||||
).then(res => {
|
||||
if (res) {
|
||||
ev.emit("onClearNote", note.id);
|
||||
db.notes
|
||||
.delete(note.id)
|
||||
.then(
|
||||
//TODO implement undo
|
||||
() => {
|
||||
showSnack("Note deleted!", Icon.Check);
|
||||
ev.emit("refreshNotes");
|
||||
}
|
||||
)
|
||||
.catch(console.log);
|
||||
function menuItems(note, context) {
|
||||
return [
|
||||
{ title: "colors", component: Colors },
|
||||
{
|
||||
title: note.notebook ? "Move" : "Add to",
|
||||
onClick: async () => {
|
||||
if (await showMoveNoteDialog([note.id])) {
|
||||
console.log("Note moved successfully!");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function sendOpenNoteEvent(note) {
|
||||
ev.emit("onOpenNote", note);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: note.pinned ? "Unpin" : "Pin",
|
||||
onClick: () => store.pin(note),
|
||||
},
|
||||
{
|
||||
title: note.favorite ? "Unfavorite" : "Favorite",
|
||||
onClick: () => store.favorite(note),
|
||||
},
|
||||
{ title: "Edit", onClick: () => editorStore.openSession(note) },
|
||||
{
|
||||
title: note.locked ? "Unlock" : "Lock",
|
||||
onClick: async () => {
|
||||
const { unlock, lock } = store;
|
||||
if (!note.locked) {
|
||||
lock(note.id);
|
||||
} else {
|
||||
unlock(note.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
visible: context ? (context.type === "topic" ? true : false) : false,
|
||||
title: "Remove",
|
||||
onClick: async () => {
|
||||
confirm(
|
||||
Icon.Topic,
|
||||
"Remove from Topic",
|
||||
"Are you sure you want to remove this note?"
|
||||
).then(async (res) => {
|
||||
if (res) {
|
||||
await db.notebooks
|
||||
.notebook(context.notebook.id)
|
||||
.topics.topic(context.value)
|
||||
.delete(note.id);
|
||||
await store.setContext(context);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Move to Trash",
|
||||
color: "red",
|
||||
onClick: async () => {
|
||||
if (note.locked) {
|
||||
const res = await showPasswordDialog("unlock_note", (password) => {
|
||||
return db.vault
|
||||
.unlock(password)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
});
|
||||
if (!res) return;
|
||||
}
|
||||
confirm(
|
||||
Icon.Trash,
|
||||
"Delete",
|
||||
"Are you sure you want to delete this note?"
|
||||
).then(async (res) => {
|
||||
if (res) {
|
||||
await store.delete(note.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const Note = ({ item, index }) => {
|
||||
function Note(props) {
|
||||
const { item, index } = props;
|
||||
const note = item;
|
||||
return note ? (
|
||||
const selectedNote = useStore((store) => store.selectedNote);
|
||||
const isOpened = selectedNote === note.id;
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<ListItem
|
||||
selectable
|
||||
focused={isOpened}
|
||||
item={note}
|
||||
title={note.title}
|
||||
body={note.headline}
|
||||
id={note.id}
|
||||
index={index}
|
||||
onClick={sendOpenNoteEvent.bind(this, note)}
|
||||
onClick={async () => {
|
||||
await editorStore.openSession(note);
|
||||
}}
|
||||
info={
|
||||
<Flex justifyContent="center" alignItems="center">
|
||||
<TimeAgo datetime={note.dateCreated} />
|
||||
{note.locked && <Icon.Lock size={16} style={{ marginLeft: 5 }} />}
|
||||
{note.favorite && <Icon.Star size={16} style={{ marginLeft: 5 }} />}
|
||||
<Flex flex="1 1 auto" justifyContent="space-between">
|
||||
<Flex variant="rowCenter">
|
||||
{note.colors.map((item, index) => (
|
||||
<Box
|
||||
key={item}
|
||||
style={{
|
||||
width: 13,
|
||||
marginLeft: index ? -8 : 0,
|
||||
marginRight: index === note.colors.length - 1 ? 5 : 0,
|
||||
height: 13,
|
||||
backgroundColor: COLORS[item],
|
||||
borderRadius: 100,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<TimeAgo datetime={note.dateCreated} />
|
||||
{note.locked && (
|
||||
<Icon.Lock
|
||||
size={13}
|
||||
color={theme.colors.fontTertiary}
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
{note.favorite && (
|
||||
<Icon.Star
|
||||
color={theme.colors.favorite}
|
||||
size={13}
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
{note.conflicted && (
|
||||
<Text
|
||||
ml={1}
|
||||
p={1}
|
||||
bg="error"
|
||||
color="static"
|
||||
sx={{ borderRadius: "default" }}
|
||||
>
|
||||
CONFLICT
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
pinned={note.pinned}
|
||||
pinned={props.pinnable && note.pinned}
|
||||
menuData={note}
|
||||
menuItems={menuItems(note)}
|
||||
dropdownRefs={dropdownRefs}
|
||||
menuItems={menuItems(note, props.context)}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
export default Note;
|
||||
export default React.memo(Note, function (prevProps, nextProps) {
|
||||
const prevItem = prevProps.item;
|
||||
const nextItem = nextProps.item;
|
||||
|
||||
return (
|
||||
prevItem.pinned === nextItem.pinned &&
|
||||
prevItem.favorite === nextItem.favorite &&
|
||||
prevItem.headline === nextItem.headline &&
|
||||
prevItem.title === nextItem.title &&
|
||||
prevItem.locked === nextItem.locked &&
|
||||
prevItem.conflicted === nextItem.conflicted &&
|
||||
JSON.stringify(prevItem.colors) === JSON.stringify(nextItem.colors)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,101 +1,88 @@
|
||||
import React from "react";
|
||||
import { Flex, Text } from "rebass";
|
||||
import * as Icon from "react-feather";
|
||||
import ListItem from "../list-item";
|
||||
import { db, ev } from "../../common";
|
||||
import { showSnack } from "../snackbar";
|
||||
import { store } from "../../stores/notebook-store";
|
||||
import { showEditNoteDialog } from "../dialogs/addnotebookdialog";
|
||||
|
||||
const dropdownRefs = [];
|
||||
const menuItems = notebook => [
|
||||
{
|
||||
title: notebook.pinned ? "Unpin" : "Pin",
|
||||
onClick: async () =>
|
||||
db.notebooks
|
||||
.notebook(notebook.id)
|
||||
.pin()
|
||||
.then(() => {
|
||||
showSnack("Notebook pinned!", Icon.Check);
|
||||
ev.emit("refreshNotebooks");
|
||||
})
|
||||
},
|
||||
{
|
||||
title: notebook.favorite ? "Unfavorite" : "Favorite",
|
||||
onClick: async () =>
|
||||
db.notebooks
|
||||
.notebook(notebook.id)
|
||||
.favorite()
|
||||
.then(() => {
|
||||
showSnack("Notebook favorited!", Icon.Check);
|
||||
ev.emit("refreshNotebooks");
|
||||
})
|
||||
},
|
||||
{ title: "Edit" },
|
||||
{ title: "Share" },
|
||||
{
|
||||
title: "Delete",
|
||||
color: "red",
|
||||
onClick: () => {
|
||||
db.notebooks.delete(notebook.id).then(
|
||||
//TODO implement undo
|
||||
() => {
|
||||
showSnack("Notebook deleted!", Icon.Check);
|
||||
ev.emit("refreshNotebooks");
|
||||
}
|
||||
);
|
||||
}
|
||||
function menuItems(notebook, index) {
|
||||
return [
|
||||
{
|
||||
title: notebook.pinned ? "Unpin" : "Pin",
|
||||
onClick: () => store.pin(notebook, index),
|
||||
},
|
||||
{
|
||||
title: "Edit",
|
||||
onClick: () => showEditNoteDialog(notebook),
|
||||
},
|
||||
{
|
||||
title: "Delete",
|
||||
color: "red",
|
||||
onClick: () => store.delete(notebook.id, index),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
class Notebook extends React.Component {
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const prevItem = this.props.item;
|
||||
const nextItem = nextProps.item;
|
||||
return (
|
||||
prevItem.pinned !== nextItem.pinned ||
|
||||
prevItem.favorite !== nextItem.favorite ||
|
||||
prevItem !== nextItem
|
||||
);
|
||||
}
|
||||
];
|
||||
const Notebook = ({ item, index, onClick, onTopicClick }) => {
|
||||
const notebook = item;
|
||||
return (
|
||||
<ListItem
|
||||
onClick={onClick}
|
||||
title={notebook.title}
|
||||
body={notebook.description}
|
||||
subBody={
|
||||
<Flex sx={{ marginBottom: 1 }}>
|
||||
{notebook.topics.slice(1, 4).map(topic => (
|
||||
<Flex
|
||||
onClick={e => {
|
||||
onTopicClick(notebook, topic);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
key={topic.id + topic.title}
|
||||
bg="primary"
|
||||
px={2}
|
||||
py={1}
|
||||
sx={{
|
||||
marginRight: 1,
|
||||
borderRadius: "default",
|
||||
color: "static"
|
||||
}}
|
||||
>
|
||||
<Text variant="body" fontSize={11}>
|
||||
{topic.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
info={
|
||||
<Flex justifyContent="center" alignItems="center">
|
||||
{new Date(notebook.id).toDateString().substring(4)}
|
||||
<Text as="span" mx={1}>
|
||||
•
|
||||
</Text>
|
||||
<Text>{notebook.totalNotes} Notes</Text>
|
||||
{notebook.favorite && (
|
||||
<Icon.Star size={16} style={{ marginLeft: 5 }} />
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
pinned={notebook.pinned}
|
||||
dropdownRefs={dropdownRefs}
|
||||
index={index}
|
||||
menuData={notebook}
|
||||
menuItems={menuItems(notebook)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { item, index, onClick, onTopicClick } = this.props;
|
||||
const notebook = item;
|
||||
return (
|
||||
<ListItem
|
||||
selectable
|
||||
item={notebook}
|
||||
onClick={onClick}
|
||||
title={notebook.title}
|
||||
body={notebook.description}
|
||||
subBody={
|
||||
<Flex sx={{ marginBottom: 1, marginTop: 1 }}>
|
||||
{notebook.topics.slice(1, 4).map((topic) => (
|
||||
<Flex
|
||||
onClick={(e) => {
|
||||
onTopicClick(notebook, topic);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
key={topic.title}
|
||||
bg="primary"
|
||||
px={1}
|
||||
sx={{
|
||||
marginRight: 1,
|
||||
borderRadius: "default",
|
||||
color: "static",
|
||||
paddingTop: "2px",
|
||||
paddingBottom: "2px",
|
||||
}}
|
||||
>
|
||||
<Text variant="body" color="static" fontSize={11}>
|
||||
{topic.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
info={
|
||||
<Flex variant="rowCenter">
|
||||
{new Date(notebook.dateCreated).toDateString().substring(4)}
|
||||
<Text as="span" mx={1}>
|
||||
•
|
||||
</Text>
|
||||
<Text>{notebook.totalNotes} Notes</Text>
|
||||
</Flex>
|
||||
}
|
||||
pinned={notebook.pinned}
|
||||
index={index}
|
||||
menuData={notebook}
|
||||
menuItems={menuItems(notebook, index)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default Notebook;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { motion } from "framer-motion";
|
||||
import React from "react";
|
||||
import * as Icon from "../icons";
|
||||
import { Flex, Text } from "rebass";
|
||||
|
||||
//TODO refactor this
|
||||
function FavoritesPlaceholder() {
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
style={{
|
||||
position: "relative",
|
||||
height: "100px",
|
||||
alignSelf: "center",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ delay: 3, marginTop: "10px", size: 25 },
|
||||
{ delay: 8, marginTop: "20px", size: 25 },
|
||||
{ delay: 12, marginTop: "-30px", size: 25 },
|
||||
{ delay: 10, marginTop: "0px", size: 30 },
|
||||
{ delay: 7, marginTop: "-30px", size: 25 },
|
||||
{ delay: 2, marginTop: "20px", size: 25 },
|
||||
{ delay: 4, marginTop: "10px", size: 25 },
|
||||
].map((item) => (
|
||||
<motion.div
|
||||
key={item.delay}
|
||||
style={{
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
marginTop: item.marginTop,
|
||||
position: "relative",
|
||||
}}
|
||||
animate={{
|
||||
opacity: [0.1, 0.5, 1, 0.5, 0],
|
||||
scaleX: [0.7, 0.75, 0.85, 0.9, 1],
|
||||
scaleY: [0.7, 0.75, 0.85, 0.9, 1],
|
||||
}}
|
||||
transition={{
|
||||
duration: 7,
|
||||
ease: "linear",
|
||||
delay: item.delay,
|
||||
loop: Infinity,
|
||||
}}
|
||||
>
|
||||
<Icon.Star color="favorite" size={item.size} />
|
||||
</motion.div>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Text
|
||||
color="gray"
|
||||
marginTop={50}
|
||||
alignSelf="center"
|
||||
sx={{
|
||||
textAlign: "center",
|
||||
fontSize: "title",
|
||||
}}
|
||||
>
|
||||
Notes you favorite appear here.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default FavoritesPlaceholder;
|
||||
18
apps/web/src/components/placeholders/index.js
Normal file
18
apps/web/src/components/placeholders/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Flex } from "rebass";
|
||||
|
||||
function Placeholder(props) {
|
||||
const { items, renderItem } = props;
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
variant="columnCenter"
|
||||
alignSelf="stretch"
|
||||
sx={{ position: "relative" }}
|
||||
>
|
||||
{items.map(renderItem)}
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default Placeholder;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
import { Box, Flex, Text } from "rebass";
|
||||
import Placeholder from "./index";
|
||||
import { getRandom } from "../../utils/random";
|
||||
|
||||
const titles = ["Yearbook 2020", "Semester 5", "Final Project", "Ready. Go"];
|
||||
const descriptions = [
|
||||
"Thoughts & Stuff",
|
||||
"Ugh. Can't handle it anymore.",
|
||||
"Have to organize all this :(",
|
||||
"What the heck!",
|
||||
];
|
||||
function NotebooksPlaceholder() {
|
||||
return (
|
||||
<Placeholder
|
||||
items={[0, 1]}
|
||||
renderItem={(item) => (
|
||||
<Flex
|
||||
key={"notebook" + item}
|
||||
mt={-80 * item}
|
||||
ml={80 * item}
|
||||
opacity={!item ? 0.7 : 1}
|
||||
height="150px"
|
||||
sx={{
|
||||
zIndex: item,
|
||||
bg: "hover",
|
||||
borderRadius: "default",
|
||||
boxShadow: "2px 2px 7px 0px #00000040",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
variant="columnCenter"
|
||||
bg="background"
|
||||
mr={2}
|
||||
width={120}
|
||||
sx={{
|
||||
borderRadius: "default",
|
||||
boxShadow: "1px 1px 5px 0px #00000060",
|
||||
}}
|
||||
>
|
||||
<Box m={3} height={10} width={"100%"} bg="primary" />
|
||||
<Text
|
||||
color="primary"
|
||||
fontSize={"body"}
|
||||
fontFamily="heading"
|
||||
textAlign="center"
|
||||
px={1}
|
||||
>
|
||||
{titles[getRandom(0, 3)]}
|
||||
<Text color="dimPrimary" wrap="wrap" fontSize="subBody">
|
||||
{descriptions[getRandom(0, 3)]}
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
></Placeholder>
|
||||
);
|
||||
}
|
||||
export default NotebooksPlaceholder;
|
||||
56
apps/web/src/components/placeholders/notesplacholder.js
Normal file
56
apps/web/src/components/placeholders/notesplacholder.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
import { Text, Flex, Box } from "rebass";
|
||||
import Placeholder from "./index";
|
||||
import * as Icon from "../icons";
|
||||
|
||||
const icons = [Icon.Vault, Icon.Star];
|
||||
const lines = Array(5).fill(0);
|
||||
|
||||
function NotesPlaceholder() {
|
||||
return (
|
||||
<Placeholder
|
||||
items={[0, 1]}
|
||||
renderItem={(_, index) => {
|
||||
const Icon = icons[index];
|
||||
return (
|
||||
<Flex
|
||||
key={"note-" + index}
|
||||
width="55%"
|
||||
bg="bgSecondary"
|
||||
opacity={0.9}
|
||||
mt={index && -50}
|
||||
ml={-40 + index * 60}
|
||||
sx={{
|
||||
boxShadow: "2px 2px 7px 0px #00000040",
|
||||
borderRadius: "default",
|
||||
}}
|
||||
>
|
||||
<Flex flex="0.06 1 auto" bg="primary" sx={{ borderRadius: 25 }} />
|
||||
<Flex flex="1 1 auto" flexDirection="column" p={1}>
|
||||
<Flex alignItems="center" justifyContent="space-between">
|
||||
<Text variant="title" color="primary" mb={1}>
|
||||
Title
|
||||
</Text>
|
||||
<Icon size={20} color="dimPrimary" />
|
||||
</Flex>
|
||||
<Flex flexDirection="column">
|
||||
{lines.map((_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
width={"100%"}
|
||||
py={"4px"}
|
||||
mr={1}
|
||||
my={"3px"}
|
||||
sx={{ borderRadius: "25px" }}
|
||||
bg={"dimPrimary"}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default NotesPlaceholder;
|
||||
84
apps/web/src/components/placeholders/tags-placeholder.js
Normal file
84
apps/web/src/components/placeholders/tags-placeholder.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Animated from "../animated";
|
||||
import { Text, Flex } from "rebass";
|
||||
import { getRandom } from "../../utils/random";
|
||||
import Placeholder from "./index";
|
||||
import { useAnimation } from "framer-motion";
|
||||
|
||||
const tags = [
|
||||
"presentations",
|
||||
"quotesonlife",
|
||||
"workinprogress",
|
||||
"goodlife",
|
||||
"school",
|
||||
"lessons",
|
||||
"essays",
|
||||
"disasters",
|
||||
"todolists",
|
||||
"myschoolwork",
|
||||
];
|
||||
|
||||
function TagsPlaceholder() {
|
||||
return (
|
||||
<Placeholder
|
||||
items={[0]}
|
||||
renderItem={() => (
|
||||
<Flex
|
||||
key="tagplaceholder"
|
||||
width={"80%"}
|
||||
opacity={0.9}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={2}
|
||||
sx={{
|
||||
borderRadius: "default",
|
||||
}}
|
||||
>
|
||||
<Text fontSize={40} color="primary">
|
||||
#
|
||||
</Text>
|
||||
<TagSlider />
|
||||
</Flex>
|
||||
)}
|
||||
></Placeholder>
|
||||
);
|
||||
}
|
||||
export default TagsPlaceholder;
|
||||
|
||||
function TagSlider() {
|
||||
const [tag, setTag] = useState(getRandomTag());
|
||||
const controls = useAnimation();
|
||||
useEffect(() => {
|
||||
animate(controls, setTag);
|
||||
}, [controls]);
|
||||
return (
|
||||
<Animated.Box
|
||||
initial={{ opacity: 0, y: -50 }}
|
||||
animate={controls}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
sx={{ fontSize: 40, color: "primary" }}
|
||||
>
|
||||
{tag}
|
||||
</Animated.Box>
|
||||
);
|
||||
}
|
||||
|
||||
async function animate(controls, setTag) {
|
||||
await controls.start({ opacity: 1, y: 0 });
|
||||
await controls.start({
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
transition: { delay: 1, duration: 0.4 },
|
||||
});
|
||||
await controls.start({
|
||||
opacity: 0,
|
||||
y: -50,
|
||||
transition: { duration: 0 },
|
||||
});
|
||||
setTag(getRandomTag());
|
||||
await animate(controls, setTag);
|
||||
}
|
||||
|
||||
function getRandomTag() {
|
||||
return tags[getRandom(0, tags.length - 1)];
|
||||
}
|
||||
@@ -1,168 +1,184 @@
|
||||
import React from "react";
|
||||
import * as Icon from "react-feather";
|
||||
import { Box, Flex, Text } from "rebass";
|
||||
import * as Icon from "../icons";
|
||||
import { Flex, Text, Button } from "rebass";
|
||||
import { Input } from "@rebass/forms";
|
||||
import CheckBox from "../checkbox";
|
||||
import { PinIcon } from "../icons";
|
||||
import { usePersistentState } from "../../utils/hooks";
|
||||
import { useStore } from "../../stores/editor-store";
|
||||
import { COLORS } from "../../common";
|
||||
import { objectMap } from "../../utils/object";
|
||||
import { useStore as useAppStore } from "../../stores/app-store";
|
||||
import Animated from "../animated";
|
||||
import Toggle from "./toggle";
|
||||
import { toTitleCase } from "../../utils/string";
|
||||
|
||||
const tools = [
|
||||
{ key: "pinned", icons: { on: Icon.PinFilled, off: Icon.Pin }, label: "Pin" },
|
||||
{
|
||||
key: "favorite",
|
||||
icons: { on: Icon.Star, off: Icon.StarOutline },
|
||||
label: "Favorite",
|
||||
},
|
||||
{ key: "locked", icons: { on: Icon.Lock, off: Icon.Unlock }, label: "Lock" },
|
||||
];
|
||||
|
||||
function Properties() {
|
||||
const colors = useStore((store) => store.session.colors);
|
||||
const toggleLocked = useStore((store) => store.toggleLocked);
|
||||
const tags = useStore((store) => store.session.tags);
|
||||
const setSession = useStore((store) => store.setSession);
|
||||
const setColor = useStore((store) => store.setColor);
|
||||
const setTag = useStore((store) => store.setTag);
|
||||
const arePropertiesVisible = useStore((store) => store.arePropertiesVisible);
|
||||
const toggleProperties = useStore((store) => store.toggleProperties);
|
||||
const isFocusMode = useAppStore((store) => store.isFocusMode);
|
||||
|
||||
function changeState(prop, value) {
|
||||
if (prop === "locked") {
|
||||
toggleLocked();
|
||||
return;
|
||||
}
|
||||
setSession((state) => {
|
||||
state.session[prop] = value;
|
||||
});
|
||||
}
|
||||
|
||||
const Properties = props => {
|
||||
const [visible, setVisible] = usePersistentState("propertiesVisible", false);
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
onClick={() => setVisible(true)}
|
||||
sx={{
|
||||
display: visible ? "none" : "flex",
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
right: 0,
|
||||
color: "static",
|
||||
borderRadius: "100px 0px 0px 100px",
|
||||
cursor: "pointer",
|
||||
height: [0, 0, 60]
|
||||
}}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="primary"
|
||||
>
|
||||
<Icon.ChevronLeft size={32} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: visible ? "flex" : "none",
|
||||
borderLeft: "1px solid",
|
||||
borderColor: "border",
|
||||
width: [0, 0, "20%"]
|
||||
}}
|
||||
flexDirection="column"
|
||||
bg="background"
|
||||
px={3}
|
||||
py={0}
|
||||
>
|
||||
<Text
|
||||
variant="title"
|
||||
color="primary"
|
||||
my={2}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ display: "flex" }}
|
||||
>
|
||||
Properties
|
||||
<Text
|
||||
as="span"
|
||||
onClick={() => setVisible(false)}
|
||||
sx={{
|
||||
color: "red",
|
||||
height: 24,
|
||||
":active": { color: "darkRed" }
|
||||
}}
|
||||
>
|
||||
<Icon.X />
|
||||
</Text>
|
||||
</Text>
|
||||
<CheckBox
|
||||
checked={props.pinned}
|
||||
icon={PinIcon}
|
||||
label="Pin"
|
||||
onChecked={props.onPinned}
|
||||
/>
|
||||
<CheckBox
|
||||
icon={Icon.Star}
|
||||
checked={props.favorite}
|
||||
label="Favorite"
|
||||
onChecked={props.onFavorited}
|
||||
/>
|
||||
<CheckBox icon={Icon.Lock} label="Lock" onChecked={props.onLocked} />
|
||||
<Flex fontSize="body" sx={{ marginBottom: 3 }} alignItems="center">
|
||||
<Icon.Book size={18} />
|
||||
<Text sx={{ marginLeft: 1 }}>Move to notebook</Text>
|
||||
</Flex>
|
||||
<Flex fontSize="body" sx={{ marginBottom: 2 }} alignItems="center">
|
||||
<Icon.Tag size={18} />
|
||||
<Text sx={{ marginLeft: 1 }}>Tags:</Text>
|
||||
</Flex>
|
||||
<Input
|
||||
variant="default"
|
||||
placeholder="#tag"
|
||||
sx={{ marginBottom: 2 }}
|
||||
onKeyUp={event => {
|
||||
if (
|
||||
event.key === "Enter" ||
|
||||
event.key === " " ||
|
||||
event.key === ","
|
||||
) {
|
||||
props.addTag && props.addTag(event.target.value);
|
||||
event.target.value = "";
|
||||
}
|
||||
!isFocusMode && (
|
||||
<>
|
||||
<Animated.Flex
|
||||
animate={{ x: arePropertiesVisible ? 0 : 800 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
bounceDamping: 1,
|
||||
bounceStiffness: 1,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
/>
|
||||
<Flex
|
||||
fontSize="body"
|
||||
sx={{ marginBottom: 2 }}
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
flexWrap="wrap"
|
||||
initial={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
width: 300,
|
||||
height: "100%",
|
||||
}}
|
||||
onBlur={() => toggleProperties()}
|
||||
>
|
||||
{props.tags &&
|
||||
props.tags.map(tag => (
|
||||
<Flex
|
||||
sx={{
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
width: [0, 0, "100%"],
|
||||
height: "100%",
|
||||
borderLeft: "1px solid",
|
||||
borderLeftColor: "border",
|
||||
}}
|
||||
flexDirection="column"
|
||||
bg="background"
|
||||
px={3}
|
||||
py={0}
|
||||
>
|
||||
<Text
|
||||
variant="title"
|
||||
my={2}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
sx={{ display: "flex" }}
|
||||
>
|
||||
Properties
|
||||
<Text
|
||||
as="span"
|
||||
onClick={() => toggleProperties()}
|
||||
sx={{
|
||||
backgroundColor: "primary",
|
||||
color: "static",
|
||||
borderRadius: "default",
|
||||
padding: "2px 5px 2px 5px",
|
||||
marginBottom: 1,
|
||||
marginRight: 1
|
||||
color: "red",
|
||||
height: 24,
|
||||
":active": { color: "darkRed" },
|
||||
}}
|
||||
>
|
||||
#{tag}
|
||||
<Icon.Close />
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
<Flex fontSize="body" sx={{ marginBottom: 2 }} alignItems="center">
|
||||
<Icon.Octagon size={18} />
|
||||
<Text sx={{ marginLeft: 1 }}>Colors:</Text>
|
||||
</Flex>
|
||||
<Flex flexWrap="wrap" sx={{ marginBottom: 2 }}>
|
||||
{[
|
||||
{ label: "red", code: "#ed2d37" },
|
||||
{ label: "orange", code: "#ec6e05" },
|
||||
{ label: "yellow", code: "yellow" },
|
||||
{ label: "green", code: "green" },
|
||||
{ label: "blue", code: "blue" },
|
||||
{ label: "purple", code: "purple" },
|
||||
{ label: "gray", code: "gray" }
|
||||
].map(color => (
|
||||
<Flex
|
||||
sx={{ position: "relative" }}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
onClick={() => props.colorSelected && props.colorSelected(color)}
|
||||
key={color.label}
|
||||
>
|
||||
<Icon.Circle
|
||||
size={40}
|
||||
style={{ cursor: "pointer" }}
|
||||
fill={color.code}
|
||||
strokeWidth={0}
|
||||
/>
|
||||
{props.selectedColors &&
|
||||
props.selectedColors.includes(color.label) && (
|
||||
<Icon.Check
|
||||
style={{
|
||||
position: "absolute",
|
||||
color: "white"
|
||||
}}
|
||||
size={20}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
<Flex mb={1}>
|
||||
{tools.map((tool, _) => (
|
||||
<Toggle
|
||||
{...tool}
|
||||
key={tool.key}
|
||||
toggleKey={tool.key}
|
||||
onToggle={(state) => changeState(tool.key, state)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
<Button color="static">Add to notebook</Button>
|
||||
<Flex flexDirection="column">
|
||||
{objectMap(COLORS, (label, code) => (
|
||||
<Flex
|
||||
key={label}
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
onClick={() => setColor(label)}
|
||||
sx={{ cursor: "pointer" }}
|
||||
mt={4}
|
||||
>
|
||||
<Flex key={label}>
|
||||
<Icon.Circle size={24} color={code} />
|
||||
<Text ml={1} color="text" variant="body">
|
||||
{toTitleCase(label)}
|
||||
</Text>
|
||||
</Flex>
|
||||
{colors.includes(label) && (
|
||||
<Icon.Checkmark color="primary" size={20} />
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Properties;
|
||||
<Input
|
||||
placeholder="#tag"
|
||||
mt={4}
|
||||
onKeyUp={(event) => {
|
||||
if (
|
||||
event.key === "Enter" ||
|
||||
event.key === " " ||
|
||||
event.key === ","
|
||||
) {
|
||||
const value = event.target.value;
|
||||
if (value.trim().length === 0) {
|
||||
event.target.value = "";
|
||||
return;
|
||||
}
|
||||
setTag(value.trim().replace(",", ""));
|
||||
event.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Flex
|
||||
fontSize="body"
|
||||
sx={{ marginBottom: 2 }}
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<Text
|
||||
key={tag}
|
||||
sx={{
|
||||
backgroundColor: "primary",
|
||||
color: "static",
|
||||
borderRadius: "default",
|
||||
padding: "2px 5px 2px 5px",
|
||||
marginBottom: 1,
|
||||
marginRight: 1,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
setTag(tag);
|
||||
}}
|
||||
>
|
||||
#{tag}
|
||||
</Text>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Animated.Flex>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
export default React.memo(Properties);
|
||||
|
||||
24
apps/web/src/components/properties/toggle.js
Normal file
24
apps/web/src/components/properties/toggle.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { Flex, Text } from "rebass";
|
||||
import { useStore } from "../../stores/editor-store";
|
||||
|
||||
function Toggle(props) {
|
||||
const { icons, label, onToggle, toggleKey } = props;
|
||||
const isOn = useStore((store) => store.session[toggleKey]);
|
||||
return (
|
||||
<Flex
|
||||
variant="columnCenter"
|
||||
width="33%"
|
||||
py={2}
|
||||
mr={1}
|
||||
sx={{ borderRadius: "default", cursor: "pointer" }}
|
||||
onClick={() => onToggle(!isOn)}
|
||||
>
|
||||
{isOn ? <icons.on color="primary" /> : <icons.off />}
|
||||
<Text mt={1} variant="body" color={isOn ? "primary" : "text"}>
|
||||
{label}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default Toggle;
|
||||
@@ -1,37 +1,50 @@
|
||||
import React from "react";
|
||||
import { Flex, Box } from "rebass";
|
||||
import { Input } from "@rebass/forms";
|
||||
import * as Icon from "react-feather";
|
||||
import * as Icon from "../icons";
|
||||
import { useStore } from "../../stores/searchstore";
|
||||
import "./search.css";
|
||||
const Search = props => (
|
||||
<Flex
|
||||
px={2}
|
||||
flexDirection="row"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
position: "relative"
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
id="searchInput"
|
||||
variant="search"
|
||||
name="search"
|
||||
placeholder={props.placeholder}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
<Box
|
||||
id="searchIcon"
|
||||
import RootNavigator from "../../navigation/navigators/rootnavigator";
|
||||
|
||||
var query = "";
|
||||
function Search(props) {
|
||||
const search = useStore(store => store.search);
|
||||
return (
|
||||
<Flex
|
||||
variant="rowCenter"
|
||||
px={2}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
marginRight: 20,
|
||||
color: "hover",
|
||||
height: 24
|
||||
position: "relative"
|
||||
}}
|
||||
>
|
||||
<Icon.Search />
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
<Input
|
||||
id="searchInput"
|
||||
name="search"
|
||||
autoFocus={!!query}
|
||||
defaultValue={query}
|
||||
placeholder={`Search your ${props.type}`}
|
||||
onChange={e => {
|
||||
query = e.target.value;
|
||||
if (query.length >= 1) {
|
||||
search(query);
|
||||
RootNavigator.navigate("search");
|
||||
} else {
|
||||
RootNavigator.goBack();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
id="searchIcon"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
mr: 3,
|
||||
color: "hover"
|
||||
}}
|
||||
>
|
||||
<Icon.Search size={28} />
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default Search;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Flex, Text } from "rebass";
|
||||
import { ThemeProvider } from "../../utils/theme";
|
||||
|
||||
export function showSnack(message, icon = undefined) {
|
||||
const root = document.getElementById("snackbarContainer");
|
||||
if (root) {
|
||||
ReactDOM.render(<Snackbar message={message} Icon={icon} />, root);
|
||||
setTimeout(() => {
|
||||
const snackbar = document.getElementById("snackbar");
|
||||
if (!snackbar) return;
|
||||
setTimeout(() => ReactDOM.unmountComponentAtNode(root), 700);
|
||||
snackbar.animate(
|
||||
{
|
||||
opacity: [1, 0],
|
||||
transform: ["translateY(0px)", "translateY(500px)"]
|
||||
},
|
||||
1000
|
||||
);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
const Snackbar = props => (
|
||||
<ThemeProvider>
|
||||
<Flex
|
||||
id="snackbar"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
left: "50%",
|
||||
textAlign: "center",
|
||||
color: "static",
|
||||
fontFamily: "body",
|
||||
fontWeight: "body",
|
||||
borderRadius: "default",
|
||||
animation: "1s ease-out fadeUp"
|
||||
}}
|
||||
alignItems="center"
|
||||
flexDirection="row"
|
||||
bg="primary"
|
||||
py={2}
|
||||
px={2}
|
||||
>
|
||||
{props.Icon && <props.Icon size={18} />}
|
||||
<Text mx={1}>{props.message}</Text>
|
||||
</Flex>
|
||||
</ThemeProvider>
|
||||
);
|
||||
86
apps/web/src/components/spliteditor/deltatoggle.js
Normal file
86
apps/web/src/components/spliteditor/deltatoggle.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import { Flex, Text, Button } from "rebass";
|
||||
import { timeConverter } from "../../utils/time";
|
||||
import DeltaTransformer from "quill/core/delta";
|
||||
import { useStore } from "../../stores/mergestore";
|
||||
|
||||
const deltaTransformer = new DeltaTransformer();
|
||||
|
||||
function DeltaToggle(props) {
|
||||
const {
|
||||
isSelected,
|
||||
isOtherSelected,
|
||||
onToggle,
|
||||
direction,
|
||||
sx,
|
||||
label,
|
||||
dateEdited,
|
||||
editors,
|
||||
} = props;
|
||||
const resolveConflict = useStore((store) => store.resolveConflict);
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" sx={sx}>
|
||||
<Flex>
|
||||
{isOtherSelected && (
|
||||
<Button
|
||||
variant="primary"
|
||||
mr={2}
|
||||
onClick={async () => {
|
||||
const { selectedEditor, otherEditor } = editors();
|
||||
await resolveConflict(
|
||||
{
|
||||
delta: deltaTransformer.cleanDifference(
|
||||
selectedEditor.getContents()
|
||||
),
|
||||
text: selectedEditor.getText(),
|
||||
},
|
||||
{
|
||||
delta: deltaTransformer.cleanDifference(
|
||||
otherEditor.getContents()
|
||||
),
|
||||
text: otherEditor.getText(),
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
Save copy
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={async () => {
|
||||
if (isOtherSelected) {
|
||||
const { selectedEditor } = editors();
|
||||
await resolveConflict({
|
||||
delta: deltaTransformer.cleanDifference(
|
||||
selectedEditor.getContents()
|
||||
),
|
||||
text: selectedEditor.getText(),
|
||||
});
|
||||
} else {
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
bg={isOtherSelected ? "error" : "primary"}
|
||||
>
|
||||
{isSelected ? "Undo" : isOtherSelected ? "Discard" : "Keep"}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex
|
||||
flexDirection={direction}
|
||||
alignItems="center"
|
||||
mt={1}
|
||||
sx={{ fontSize: "subBody", color: "fontTertiary" }}
|
||||
>
|
||||
{label}
|
||||
{/* eslint-disable-next-line jsx-a11y/accessible-emoji */}
|
||||
<Text as="span" role="img" fontSize={7} mx={1}>
|
||||
⚫
|
||||
</Text>
|
||||
{timeConverter(dateEdited, true)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
export default DeltaToggle;
|
||||
102
apps/web/src/components/spliteditor/index.js
Normal file
102
apps/web/src/components/spliteditor/index.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Flex, Box, Text } from "rebass";
|
||||
import SimpleEditor from "./simpleeditor";
|
||||
import DeltaTransformer from "quill/core/delta";
|
||||
import DeltaToggle from "./deltatoggle";
|
||||
import { useStore } from "../../stores/mergestore";
|
||||
|
||||
const deltaTransformer = new DeltaTransformer();
|
||||
|
||||
function SplitEditor(props) {
|
||||
const conflictedNote = useStore((store) => store.conflictedNote);
|
||||
const remoteDelta = useStore((store) => store.remoteDelta);
|
||||
const localDelta = useStore((store) => store.localDelta);
|
||||
const [localEditor, remoteEditor] = [useRef(), useRef()];
|
||||
const [selectedDelta, setSelectedDelta] = useState(-1);
|
||||
if (!conflictedNote) return null;
|
||||
return (
|
||||
<Flex width="100%" flex="1 1 auto" flexDirection="column">
|
||||
<Flex
|
||||
sx={{
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "border",
|
||||
position: "relative",
|
||||
}}
|
||||
justifyContent="stretch"
|
||||
alignItems="center"
|
||||
p={2}
|
||||
>
|
||||
<DeltaToggle
|
||||
sx={{ flex: "0.1 1 auto" }}
|
||||
label="Current note"
|
||||
dateEdited={localDelta.dateEdited}
|
||||
isSelected={selectedDelta === 0}
|
||||
isOtherSelected={selectedDelta === 1}
|
||||
onToggle={() => setSelectedDelta((s) => (s === 0 ? -1 : 0))}
|
||||
editors={() => ({
|
||||
selectedEditor: remoteEditor.current.quill,
|
||||
otherEditor: localEditor.current.quill,
|
||||
})}
|
||||
/>
|
||||
<Text
|
||||
flex="0.8 1 auto"
|
||||
variant="heading"
|
||||
textAlign="center"
|
||||
sx={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
{conflictedNote.title}
|
||||
</Text>
|
||||
<DeltaToggle
|
||||
sx={{ flex: "0.1 1 auto", alignItems: "flex-end" }}
|
||||
direction="row-reverse"
|
||||
label="Incoming note"
|
||||
isSelected={selectedDelta === 1}
|
||||
isOtherSelected={selectedDelta === 0}
|
||||
dateEdited={remoteDelta.dateEdited}
|
||||
onToggle={() => setSelectedDelta((s) => (s === 1 ? -1 : 1))}
|
||||
editors={() => ({
|
||||
selectedEditor: localEditor.current.quill,
|
||||
otherEditor: remoteEditor.current.quill,
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex flex="1 1 auto">
|
||||
<Box
|
||||
className="firstEditor"
|
||||
width="50%"
|
||||
flex="1 1 auto"
|
||||
sx={{ borderRight: "1px solid", borderColor: "border" }}
|
||||
>
|
||||
<SimpleEditor
|
||||
pref={localEditor}
|
||||
container=".firstEditor"
|
||||
id="firstQuill"
|
||||
delta={deltaTransformer.highlightDifference(
|
||||
localDelta.data,
|
||||
remoteDelta.data,
|
||||
"#FDB0C0"
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box className="secondEditor" flex="1 1 auto" width="50%">
|
||||
<SimpleEditor
|
||||
pref={remoteEditor}
|
||||
container=".secondEditor"
|
||||
id="secondQuill"
|
||||
delta={deltaTransformer.highlightDifference(
|
||||
remoteDelta.data,
|
||||
localDelta.data,
|
||||
"#CAFFFB"
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default SplitEditor;
|
||||
21
apps/web/src/components/spliteditor/simpleeditor.js
Normal file
21
apps/web/src/components/spliteditor/simpleeditor.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import React, { useEffect } from "react";
|
||||
import ReactQuill from "../editor/react-quill";
|
||||
|
||||
function SimpleEditor(props) {
|
||||
const { delta, container, id, pref } = props;
|
||||
useEffect(() => {
|
||||
const toolbar = document.querySelector(`${container} .ql-toolbar.ql-snow`);
|
||||
toolbar.remove();
|
||||
}, [container]);
|
||||
return (
|
||||
<ReactQuill
|
||||
ref={pref}
|
||||
id={id}
|
||||
modules={{ toolbar: [] }}
|
||||
initialContent={delta}
|
||||
placeholder="Type anything here"
|
||||
container={container}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default SimpleEditor;
|
||||
23
apps/web/src/components/theme-provider/index.js
Normal file
23
apps/web/src/components/theme-provider/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { ThemeProvider as EmotionThemeProvider } from "emotion-theming";
|
||||
import { useStore } from "../../stores/theme-store";
|
||||
import ThemeFactory from "../../theme";
|
||||
import { injectCss } from "../../utils/css";
|
||||
|
||||
const factory = new ThemeFactory();
|
||||
|
||||
function ThemeProvider(props) {
|
||||
const themeType = useStore(store => store.theme);
|
||||
const accent = useStore(store => store.accent);
|
||||
const theme = factory.construct({ theme: themeType, accent, scale: 1 });
|
||||
injectCss(factory.transform("css", theme));
|
||||
|
||||
return (
|
||||
<EmotionThemeProvider theme={theme}>
|
||||
{props.children instanceof Function
|
||||
? props.children(theme)
|
||||
: props.children}
|
||||
</EmotionThemeProvider>
|
||||
);
|
||||
}
|
||||
export default ThemeProvider;
|
||||
@@ -1,38 +1,26 @@
|
||||
import React from "react";
|
||||
import { Text } from "rebass";
|
||||
import * as Icon from "react-feather";
|
||||
import ListItem from "../list-item";
|
||||
|
||||
const dropdownRefs = [];
|
||||
const menuItems = [
|
||||
{ title: "Favorite", icon: Icon.Heart },
|
||||
{ title: "Share", icon: Icon.Share2 },
|
||||
{
|
||||
title: "Delete",
|
||||
icon: Icon.Trash,
|
||||
color: "red"
|
||||
}
|
||||
color: "red",
|
||||
},
|
||||
];
|
||||
|
||||
const Topic = ({ item, index, onClick }) => {
|
||||
function Topic({ item, index, onClick }) {
|
||||
const topic = item;
|
||||
return (
|
||||
<ListItem
|
||||
selectable
|
||||
item={topic}
|
||||
onClick={onClick}
|
||||
title={topic.title}
|
||||
info={
|
||||
<Text>
|
||||
<Text as="span" color="text">
|
||||
{topic.totalNotes} Notes
|
||||
</Text>
|
||||
</Text>
|
||||
}
|
||||
info={`${topic.totalNotes} Notes`}
|
||||
index={index}
|
||||
dropdownRefs={dropdownRefs}
|
||||
menuData={topic}
|
||||
menuItems={menuItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
}
|
||||
export default Topic;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
@import url("https://fonts.googleapis.com/css?family=Noto+Sans:400,700|Noto+Serif:400,700&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css?family=Cinzel:700|Playfair+Display+SC:700|Rufina:700&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,400;0,500;0,700;1,400;1,500;1,700&family=DM+Serif+Text:ital@0;1&display=swap");
|
||||
|
||||
:root,
|
||||
body,
|
||||
@@ -18,7 +17,7 @@ body,
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: "Noto Sans", sans-serif;
|
||||
font-family: "DM Sans", sans-serif;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
@@ -34,3 +33,7 @@ body,
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "react-app-polyfill/ie11";
|
||||
import "react-app-polyfill/ie9";
|
||||
import "./utils/defer";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import "./index.css";
|
||||
|
||||
106
apps/web/src/interfaces/crypto.js
Normal file
106
apps/web/src/interfaces/crypto.js
Normal file
@@ -0,0 +1,106 @@
|
||||
class Crypto {
|
||||
isReady = false;
|
||||
constructor() {
|
||||
this.sodium = undefined;
|
||||
}
|
||||
async _initialize() {
|
||||
if (this.isReady) return;
|
||||
const { default: _sodium } = await import("libsodium-wrappers");
|
||||
await _sodium.ready;
|
||||
this.sodium = _sodium;
|
||||
this.isReady = true;
|
||||
}
|
||||
|
||||
deriveKey = async (password, salt, exportKey = false) => {
|
||||
await this._initialize();
|
||||
|
||||
if (!salt)
|
||||
salt = this.sodium.randombytes_buf(this.sodium.crypto_pwhash_SALTBYTES);
|
||||
else {
|
||||
salt = this.sodium.from_base64(salt);
|
||||
}
|
||||
|
||||
const key = this.sodium.crypto_pwhash(
|
||||
this.sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES,
|
||||
password,
|
||||
salt,
|
||||
3, // operations limit
|
||||
1024 * 1024 * 8, // memory limit (8MB)
|
||||
this.sodium.crypto_pwhash_ALG_ARGON2I13,
|
||||
exportKey ? "base64" : "uint8array"
|
||||
);
|
||||
const saltHex = this.sodium.to_base64(salt);
|
||||
this.sodium.memzero(salt);
|
||||
if (exportKey) {
|
||||
return key;
|
||||
}
|
||||
return { key, salt: saltHex };
|
||||
};
|
||||
|
||||
_getKey = async (passwordOrKey) => {
|
||||
let { salt, key, password } = passwordOrKey;
|
||||
if (password) {
|
||||
const result = await this.deriveKey(password, salt);
|
||||
key = result.key;
|
||||
salt = result.salt;
|
||||
} else if (key && salt) {
|
||||
salt = passwordOrKey.salt;
|
||||
key = this.sodium.from_base64(key);
|
||||
}
|
||||
return { key, salt };
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
|
||||
* @param {string|Object} data - the plaintext data
|
||||
*/
|
||||
encrypt = async (passwordOrKey, data) => {
|
||||
await this._initialize();
|
||||
|
||||
const { key, salt } = await this._getKey(passwordOrKey);
|
||||
|
||||
const nonce = this.sodium.randombytes_buf(
|
||||
this.sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
|
||||
);
|
||||
const cipher = this.sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
data,
|
||||
undefined,
|
||||
undefined,
|
||||
nonce,
|
||||
key,
|
||||
"base64"
|
||||
);
|
||||
const iv = this.sodium.to_base64(nonce);
|
||||
this.sodium.memzero(nonce);
|
||||
this.sodium.memzero(key);
|
||||
return {
|
||||
cipher,
|
||||
iv,
|
||||
salt,
|
||||
length: data.length,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {{password: string}|{key:string, salt: string}} passwordOrKey - password or derived key
|
||||
* @param {{salt: string, iv: string, cipher: string}} cipher - the cipher data
|
||||
*/
|
||||
decrypt = async (passwordOrKey, { iv, cipher, salt }) => {
|
||||
await this._initialize();
|
||||
const { key } = await this._getKey({ salt, ...passwordOrKey });
|
||||
|
||||
const plainText = this.sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
undefined,
|
||||
this.sodium.from_base64(cipher),
|
||||
undefined,
|
||||
this.sodium.from_base64(iv),
|
||||
key,
|
||||
"text"
|
||||
);
|
||||
this.sodium.memzero(key);
|
||||
return plainText;
|
||||
};
|
||||
}
|
||||
export default Crypto;
|
||||
@@ -1,124 +1,44 @@
|
||||
import localforage from "localforage";
|
||||
import { extendPrototype } from "localforage-getitems";
|
||||
import sort from "fast-sort";
|
||||
import Crypto from "./crypto";
|
||||
|
||||
const crypto = new Crypto();
|
||||
extendPrototype(localforage);
|
||||
|
||||
localforage.config({
|
||||
name: "Notesnook",
|
||||
driver: [localforage.INDEXEDDB, localforage.WEBSQL, localforage.LOCALSTORAGE]
|
||||
driver: [localforage.INDEXEDDB, localforage.WEBSQL, localforage.LOCALSTORAGE],
|
||||
});
|
||||
|
||||
async function read(key) {
|
||||
function read(key) {
|
||||
return localforage.getItem(key);
|
||||
}
|
||||
async function write(key, data) {
|
||||
|
||||
function readMulti(keys) {
|
||||
if (keys.length <= 0) return [];
|
||||
return localforage.getItems(sort(keys).asc());
|
||||
}
|
||||
|
||||
function write(key, data) {
|
||||
return localforage.setItem(key, data);
|
||||
}
|
||||
|
||||
function remove(key) {
|
||||
return localforage.removeItem(key);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
return localforage.clear();
|
||||
}
|
||||
|
||||
function encrypt(password, data) {
|
||||
return deriveKey(password).then(key => {
|
||||
console.log(key);
|
||||
// a public value that should be generated for changes each time
|
||||
var initializationVector = new Uint8Array(16);
|
||||
crypto.getRandomValues(initializationVector);
|
||||
|
||||
return crypto.subtle
|
||||
.encrypt(
|
||||
{ name: "AES-CBC", iv: initializationVector },
|
||||
key,
|
||||
toArrayBuffer(new Buffer(data))
|
||||
)
|
||||
.then(function(encrypted) {
|
||||
let b64 = require("../utils/base64");
|
||||
return {
|
||||
iv: b64.encode(initializationVector),
|
||||
cipher: b64.encode(encrypted)
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function decrypt(password, data) {
|
||||
return deriveKey(password).then(key => {
|
||||
let b64 = require("../utils/base64");
|
||||
|
||||
return crypto.subtle
|
||||
.decrypt(
|
||||
{ name: "AES-CBC", iv: b64.decode(data.iv) },
|
||||
key,
|
||||
b64.decode(data.cipher)
|
||||
)
|
||||
.then(function(decrypted) {
|
||||
return new Buffer(b64.encode(decrypted), "base64").toString("utf8");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
read,
|
||||
readMulti,
|
||||
write,
|
||||
remove,
|
||||
clear,
|
||||
encrypt,
|
||||
decrypt
|
||||
deriveKey: crypto.deriveKey,
|
||||
encrypt: crypto.encrypt,
|
||||
decrypt: crypto.decrypt,
|
||||
};
|
||||
|
||||
function deriveKey(passphrase) {
|
||||
var keyLenBits = 256;
|
||||
var kdfname = "PBKDF2";
|
||||
var aesname = "AES-CBC"; // AES-CTR is also popular
|
||||
// 100 - probably safe even on a browser running from a raspberry pi using pure js ployfill
|
||||
// 10000 - no noticeable speed decrease on my MBP
|
||||
// 100000 - you can notice
|
||||
// 1000000 - annoyingly long
|
||||
var iterations = 5000; // something a browser on a raspberry pi or old phone could do
|
||||
var hashname = "SHA-256";
|
||||
var extractable = true;
|
||||
|
||||
// First, create a PBKDF2 "key" containing the password
|
||||
return (
|
||||
crypto.subtle
|
||||
.importKey(
|
||||
"raw",
|
||||
new Buffer(passphrase, "binary"),
|
||||
{ name: kdfname },
|
||||
false,
|
||||
["deriveKey"]
|
||||
)
|
||||
// Derive a key from the password
|
||||
.then(function(passphraseKey) {
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: kdfname,
|
||||
salt: toArrayBuffer(new Buffer("salt")),
|
||||
iterations: iterations,
|
||||
hash: hashname
|
||||
},
|
||||
passphraseKey,
|
||||
// required to be 128 (or 256) bits
|
||||
{ name: aesname, length: keyLenBits }, // Key we want
|
||||
extractable, // Extractble
|
||||
["encrypt", "decrypt"] // For new key
|
||||
);
|
||||
})
|
||||
// Export it so we can display it
|
||||
.then(function(aesKey) {
|
||||
return aesKey;
|
||||
})
|
||||
.catch(function(err) {
|
||||
window.alert("Key derivation failed: " + err.message);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function toArrayBuffer(buf) {
|
||||
var ab = new ArrayBuffer(buf.length);
|
||||
var view = new Uint8Array(ab);
|
||||
for (var i = 0; i < buf.length; ++i) {
|
||||
view[i] = buf[i];
|
||||
}
|
||||
return ab;
|
||||
}
|
||||
|
||||
10
apps/web/src/navigation/container.js
Normal file
10
apps/web/src/navigation/container.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { Flex } from "rebass";
|
||||
|
||||
function NavigationContainer(props) {
|
||||
useEffect(() => {
|
||||
props.navigator.onLoad();
|
||||
}, [props.navigator]);
|
||||
return <Flex className={props.navigator.root} variant="columnFill" />;
|
||||
}
|
||||
export default NavigationContainer;
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Box, Flex, Heading, Text } from "rebass";
|
||||
import * as Icon from "react-feather";
|
||||
import { ThemeProvider } from "../utils/theme";
|
||||
import { ev } from "../common";
|
||||
import Animated from "../components/animated";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { store as selectionStore } from "../stores/selection-store";
|
||||
import Route from "./route";
|
||||
import Config from "../utils/config";
|
||||
|
||||
export default class Navigator {
|
||||
class Navigator {
|
||||
constructor(root, routes, options = {}) {
|
||||
this.routes = routes;
|
||||
this.root = root;
|
||||
@@ -14,18 +15,39 @@ export default class Navigator {
|
||||
this.lastRoute = undefined;
|
||||
}
|
||||
|
||||
onLoad = () => {
|
||||
const opts = Config.get(this.root, {
|
||||
history: [],
|
||||
lastRoute: this.getRoute(this.options.default),
|
||||
});
|
||||
this.history = opts.history;
|
||||
this.navigate(opts.lastRoute.key, opts.lastRoute.params, true);
|
||||
};
|
||||
|
||||
getRoute(key) {
|
||||
return this.routes[key];
|
||||
}
|
||||
|
||||
setLastRoute(route) {
|
||||
this.lastRoute = route;
|
||||
// cache the route in localStorage
|
||||
// NOTE: we delete the navigator key if any so it's always new across refreshes
|
||||
const copy = { ...route, params: { ...route.params } };
|
||||
if (copy.params.navigator) delete copy.params.navigator;
|
||||
Config.set(this.root, {
|
||||
history: this.history,
|
||||
lastRoute: copy,
|
||||
});
|
||||
}
|
||||
|
||||
getRoot() {
|
||||
return document.querySelector(`.${this.root}`);
|
||||
}
|
||||
|
||||
navigate(routeName, params = {}) {
|
||||
navigate(routeName, params = {}, force = false) {
|
||||
let route = this.getRoute(routeName);
|
||||
|
||||
if (!route || this.lastRoute === route) {
|
||||
if (!force && (!route || this.lastRoute === route)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,8 +56,7 @@ export default class Navigator {
|
||||
if (this.lastRoute) {
|
||||
this.history.push(this.lastRoute);
|
||||
}
|
||||
this.lastRoute = route;
|
||||
|
||||
this.setLastRoute(route);
|
||||
return this.renderRoute(route);
|
||||
}
|
||||
|
||||
@@ -44,77 +65,56 @@ export default class Navigator {
|
||||
if (!root) {
|
||||
return false;
|
||||
}
|
||||
// exit selection mode on navigate
|
||||
selectionStore.toggleSelectionMode(false);
|
||||
ReactDOM.render(
|
||||
<NavigationContainer
|
||||
navigator={this}
|
||||
route={route}
|
||||
params={route.params}
|
||||
canGoBack={this.options.backButtonEnabled && this.history.length > 0}
|
||||
backAction={() => this.goBack()}
|
||||
/>,
|
||||
<AnimatePresence exitBeforeEnter={true}>
|
||||
<Animated.Flex
|
||||
key={route.key}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
exit={{ opacity: 0 }}
|
||||
flexDirection="column"
|
||||
flex="1 1 auto"
|
||||
>
|
||||
<Route
|
||||
navigator={this}
|
||||
route={route}
|
||||
params={route.params}
|
||||
canGoBack={
|
||||
this.options.backButtonEnabled && this.history.length > 0
|
||||
}
|
||||
backAction={() => this.goBack()}
|
||||
/>
|
||||
</Animated.Flex>
|
||||
</AnimatePresence>,
|
||||
root
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
goBack() {
|
||||
goBack(params = {}) {
|
||||
let route = this.history.pop();
|
||||
if (!route) {
|
||||
return false;
|
||||
}
|
||||
this.lastRoute = route;
|
||||
return this.renderRoute(route);
|
||||
if (!route) return false;
|
||||
if (!route.component) route.component = this.getRoute(route.key).component;
|
||||
|
||||
this.setLastRoute(route);
|
||||
return this.renderRoute(this._mergeParams(route, params));
|
||||
}
|
||||
|
||||
restore() {
|
||||
restore(params = {}) {
|
||||
if (!this.lastRoute) {
|
||||
return false;
|
||||
}
|
||||
return this.renderRoute(this.lastRoute);
|
||||
return this.renderRoute(this._mergeParams(this.lastRoute, params));
|
||||
}
|
||||
|
||||
_mergeParams(route, params) {
|
||||
return {
|
||||
...route,
|
||||
params: { ...route.params, ...params },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const NavigationContainer = props => {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Flex flexDirection="column" px={2}>
|
||||
<Flex alignItems="center">
|
||||
{props.canGoBack && (
|
||||
<Box
|
||||
onClick={props.backAction}
|
||||
height={38}
|
||||
color="fontPrimary"
|
||||
sx={{ marginLeft: -1, marginRight: 2 }}
|
||||
>
|
||||
<Icon.ChevronLeft size={38} />
|
||||
</Box>
|
||||
)}
|
||||
{!props.route.topBarHidden && (
|
||||
<>
|
||||
<Box
|
||||
onClick={() => ev.emit("openSideMenu")}
|
||||
height={36}
|
||||
color="fontPrimary"
|
||||
sx={{
|
||||
marginRight: 3,
|
||||
display: [props.canGoBack ? "none" : "block", "none", "none"]
|
||||
}}
|
||||
>
|
||||
<Icon.Menu size={36} />
|
||||
</Box>
|
||||
<Heading fontSize="heading">
|
||||
{props.route.title || props.route.params.title}
|
||||
</Heading>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
<Text variant="title" color="primary" sx={{ marginBottom: 1 }}>
|
||||
{props.route.params.subtitle}
|
||||
</Text>
|
||||
</Flex>
|
||||
{props.route.component && (
|
||||
<props.route.component navigator={props.navigator} {...props.params} />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
export default Navigator;
|
||||
|
||||
14
apps/web/src/navigation/navigators/editornavigator.js
Normal file
14
apps/web/src/navigation/navigators/editornavigator.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import Editor from "../../components/editor";
|
||||
import SplitEditor from "../../components/spliteditor";
|
||||
import Navigator from "../index";
|
||||
import { createRoute } from "../routes";
|
||||
|
||||
const routes = {
|
||||
...createRoute("editor", Editor),
|
||||
...createRoute("split", SplitEditor),
|
||||
};
|
||||
|
||||
const EditorNavigator = new Navigator("EditorNavigator", routes, {
|
||||
defaultRoute: "editor",
|
||||
});
|
||||
export default EditorNavigator;
|
||||
@@ -1,3 +1,5 @@
|
||||
export const NotebookNavigator = require("./nbnavigator").default;
|
||||
export const RootNavigator = require("./rootnavigator").default;
|
||||
export const SettingsNavigator = require("./settingnavigator").default;
|
||||
export const TagNavigator = require("./tagnavigator").default;
|
||||
//export const EditorNavigator = require("./editornavigator").default;
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { Notebooks, Notes, Topics } from "../../views";
|
||||
import Navigator from "../index";
|
||||
import { createRoute } from "../routes";
|
||||
import SelectionModeOptions from "../../common/selectionoptions";
|
||||
|
||||
const routes = {
|
||||
...createRoute("notebooks", Notebooks, { title: "Notebooks" }),
|
||||
...createRoute("topics", Topics),
|
||||
...createRoute("notes", Notes)
|
||||
...createRoute("notebooks", Notebooks, {
|
||||
title: "Notebooks",
|
||||
options: SelectionModeOptions.NotebooksOptions,
|
||||
}),
|
||||
...createRoute("topics", Topics, {
|
||||
options: SelectionModeOptions.TopicOptions,
|
||||
}),
|
||||
...createRoute("notes", Notes, {
|
||||
options: SelectionModeOptions.NotesOptions,
|
||||
}),
|
||||
};
|
||||
|
||||
const NotebookNavigator = new Navigator("NotebookNavigator", routes, {
|
||||
backButtonEnabled: true
|
||||
backButtonEnabled: true,
|
||||
default: "notebooks",
|
||||
});
|
||||
export default NotebookNavigator;
|
||||
|
||||
@@ -1,65 +1,91 @@
|
||||
import {
|
||||
Home,
|
||||
SettingsContainer,
|
||||
Favorites,
|
||||
Trash,
|
||||
NotebooksContainer
|
||||
} from "../../views";
|
||||
import * as Icon from "react-feather";
|
||||
import { Home, Trash, Notes, Account } from "../../views";
|
||||
import * as Icon from "../../components/icons";
|
||||
import {
|
||||
createRoute,
|
||||
createNavigatorRoute,
|
||||
createNormalRoute,
|
||||
createColorRoute,
|
||||
createDeadRoute
|
||||
createDeadRoute,
|
||||
} from "../routes";
|
||||
import Navigator from "../index";
|
||||
import { showSignInDialog } from "../../components/dialogs";
|
||||
import { changeTheme, isDarkTheme } from "../../utils/theme";
|
||||
import SelectionModeOptions from "../../common/selectionoptions";
|
||||
import Search from "../../views/Search";
|
||||
import { store as userStore } from "../../stores/user-store";
|
||||
import { store as themeStore } from "../../stores/theme-store";
|
||||
import { showLogInDialog } from "../../components/dialogs/logindialog";
|
||||
import { NotebookNavigator, TagNavigator, SettingsNavigator } from "./index";
|
||||
|
||||
/*For color Search*/
|
||||
const colorRoutes = {
|
||||
...createColorRoute("red", "#ed2d37"),
|
||||
...createColorRoute("orange", "#ec6e05"),
|
||||
...createColorRoute("yellow", "yellow"),
|
||||
...createColorRoute("green", "green"),
|
||||
...createColorRoute("blue", "blue"),
|
||||
...createColorRoute("purple", "purple"),
|
||||
...createColorRoute("gray", "gray")
|
||||
export const bottomRoutes = {
|
||||
...createDeadRoute("nightmode", Icon.Theme, {
|
||||
title: "Night mode",
|
||||
onClick: () => themeStore.toggleNightMode(),
|
||||
}),
|
||||
...createDeadRoute("sync", Icon.Sync, {
|
||||
title: "Sync",
|
||||
onClick: async () => userStore.sync(),
|
||||
animatable: true,
|
||||
}),
|
||||
...createNormalRoute("account", Account, Icon.User, {
|
||||
title: "Account",
|
||||
onClick: async () => {
|
||||
if (!userStore.get().isLoggedIn) {
|
||||
await showLogInDialog();
|
||||
return false;
|
||||
} else return RootNavigator.navigate("account");
|
||||
},
|
||||
}),
|
||||
...createNavigatorRoute(
|
||||
"settings",
|
||||
Icon.Settings,
|
||||
"Settings",
|
||||
SettingsNavigator
|
||||
),
|
||||
};
|
||||
|
||||
const bottomRoutes = {
|
||||
...createDeadRoute("nightmode", Icon.Moon, {
|
||||
onClick: () => changeTheme(),
|
||||
bottom: true,
|
||||
isToggled: () => isDarkTheme(),
|
||||
title: "Night mode"
|
||||
export const routes = {
|
||||
...createNormalRoute("home", Home, Icon.Home, {
|
||||
title: "Home",
|
||||
options: SelectionModeOptions.NotesOptions,
|
||||
}),
|
||||
...createDeadRoute("signin", Icon.LogIn, {
|
||||
onClick: () => showSignInDialog(Icon.LogIn, "Login", ""),
|
||||
bottom: true,
|
||||
title: "Login"
|
||||
...createNavigatorRoute(
|
||||
"notebooks",
|
||||
Icon.Notebook,
|
||||
"Notebooks",
|
||||
NotebookNavigator
|
||||
),
|
||||
...createRoute(
|
||||
"favorites",
|
||||
Notes,
|
||||
{
|
||||
icon: Icon.StarOutline,
|
||||
title: "Favorites",
|
||||
options: SelectionModeOptions.FavoritesOptions,
|
||||
},
|
||||
{
|
||||
context: { type: "favorites" },
|
||||
}
|
||||
),
|
||||
...createNormalRoute("trash", Trash, Icon.Trash, {
|
||||
title: "Trash",
|
||||
options: SelectionModeOptions.TrashOptions,
|
||||
}),
|
||||
...createRoute("settings", SettingsContainer, {
|
||||
icon: Icon.Settings,
|
||||
bottom: true,
|
||||
title: "Settings",
|
||||
topBarHidden: true
|
||||
})
|
||||
...createNavigatorRoute("tags", Icon.Tag, "Tags", TagNavigator),
|
||||
};
|
||||
|
||||
const routes = {
|
||||
...createNormalRoute("home", Home, Icon.Home),
|
||||
...createRoute("notebooks", NotebooksContainer, {
|
||||
icon: Icon.Book,
|
||||
title: "Notebooks",
|
||||
topBarHidden: true
|
||||
const invisibleRoutes = {
|
||||
...createRoute("color", Notes, {
|
||||
icon: Icon.Circle,
|
||||
options: SelectionModeOptions.NotesOptions,
|
||||
}),
|
||||
...createNormalRoute("favorites", Favorites, Icon.Star),
|
||||
...createNormalRoute("trash", Trash, Icon.Trash2),
|
||||
...bottomRoutes
|
||||
...createRoute("search", Search, { title: "Search" }),
|
||||
};
|
||||
|
||||
const RootNavigator = new Navigator("RootNavigator", routes, {
|
||||
backButtonEnabled: false
|
||||
});
|
||||
const RootNavigator = new Navigator(
|
||||
"RootNavigator",
|
||||
{ ...routes, ...bottomRoutes, ...invisibleRoutes },
|
||||
{
|
||||
backButtonEnabled: false,
|
||||
default: "home",
|
||||
}
|
||||
);
|
||||
|
||||
export default RootNavigator;
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Settings, Account, General, TOS } from "../../views";
|
||||
import { Settings, Account } from "../../views";
|
||||
import Navigator from "../index";
|
||||
import { createRoute } from "../routes";
|
||||
|
||||
const routes = {
|
||||
...createRoute("settings", Settings, { title: "Settings" }),
|
||||
...createRoute("account", Account),
|
||||
...createRoute("general", General),
|
||||
...createRoute("TOS", TOS),
|
||||
...createRoute("about", TOS),
|
||||
...createRoute("privacy", TOS)
|
||||
...createRoute("account", Account, { title: "Account" }),
|
||||
};
|
||||
|
||||
const SettingsNavigator = new Navigator("SettingsNavigator", routes, {
|
||||
backButtonEnabled: true
|
||||
backButtonEnabled: true,
|
||||
default: "settings",
|
||||
});
|
||||
|
||||
export default SettingsNavigator;
|
||||
|
||||
18
apps/web/src/navigation/navigators/tagnavigator.js
Normal file
18
apps/web/src/navigation/navigators/tagnavigator.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Notes, Tags } from "../../views";
|
||||
import Navigator from "../index";
|
||||
import { createRoute } from "../routes";
|
||||
import SelectionModeOptions from "../../common/selectionoptions";
|
||||
|
||||
const routes = {
|
||||
...createRoute("tags", Tags, { title: "Tags" }),
|
||||
...createRoute("notes", Notes, {
|
||||
options: SelectionModeOptions.NotesOptions,
|
||||
}),
|
||||
};
|
||||
|
||||
const TagNavigator = new Navigator("TagNavigator", routes, {
|
||||
backButtonEnabled: true,
|
||||
default: "tags",
|
||||
});
|
||||
|
||||
export default TagNavigator;
|
||||
123
apps/web/src/navigation/route.js
Normal file
123
apps/web/src/navigation/route.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from "react";
|
||||
import { Box, Flex, Heading, Text } from "rebass";
|
||||
import * as Icon from "../components/icons";
|
||||
import ThemeProvider from "../components/theme-provider";
|
||||
import { useStore } from "../stores/app-store";
|
||||
import { useStore as useSelectionStore } from "../stores/selection-store";
|
||||
|
||||
function Route(props) {
|
||||
const navigator = props.params.navigator || props.navigator;
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Flex flexDirection="column" px={2}>
|
||||
<Header {...props} />
|
||||
</Flex>
|
||||
{props.route.component && (
|
||||
<props.route.component navigator={navigator} {...props.params} />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
export default Route;
|
||||
|
||||
function Header(props) {
|
||||
const { route, canGoBack, backAction } = props;
|
||||
const { title, titleColor, params, options } = route;
|
||||
|
||||
const toggleSideMenu = useStore((store) => store.toggleSideMenu);
|
||||
|
||||
if (!title && !params.title) return null;
|
||||
return (
|
||||
<>
|
||||
<Flex alignItems="center" justifyContent="space-between">
|
||||
<Flex alignItems="center" py={2}>
|
||||
{canGoBack && (
|
||||
<Box onClick={backAction} ml={-2} height={38} width={38}>
|
||||
<Icon.ChevronLeft size={38} color="fontPrimary" />
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
onClick={toggleSideMenu}
|
||||
height={38}
|
||||
ml={-5}
|
||||
sx={{
|
||||
display: ["block", "none", "none"],
|
||||
}}
|
||||
>
|
||||
<Icon.Menu size={38} />
|
||||
</Box>
|
||||
<Heading fontSize="heading" color={titleColor || "text"}>
|
||||
{title || params.title}
|
||||
</Heading>
|
||||
</Flex>
|
||||
<SelectionOptions options={options} />
|
||||
</Flex>
|
||||
{params.subtitle && (
|
||||
<Text
|
||||
variant="title"
|
||||
color="primary"
|
||||
sx={{
|
||||
marginBottom: 2,
|
||||
cursor: "normal",
|
||||
}}
|
||||
>
|
||||
{params.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
<SelectionBox />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionOptions(props) {
|
||||
const { options } = props;
|
||||
|
||||
const isSelectionMode = useSelectionStore((store) => store.isSelectionMode);
|
||||
|
||||
if (!isSelectionMode || !options) return null;
|
||||
return (
|
||||
<Flex>
|
||||
{options.map((option) => (
|
||||
<Box
|
||||
key={option.key}
|
||||
onClick={option.onClick}
|
||||
mx={2}
|
||||
sx={{ cursor: "pointer" }}
|
||||
>
|
||||
<option.icon />
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionBox() {
|
||||
const toggleSelectionMode = useSelectionStore(
|
||||
(store) => store.toggleSelectionMode
|
||||
);
|
||||
const selectAll = useSelectionStore((store) => store.selectAll);
|
||||
const shouldSelectAll = useSelectionStore((store) => store.shouldSelectAll);
|
||||
const isSelectionMode = useSelectionStore((store) => store.isSelectionMode);
|
||||
|
||||
if (!isSelectionMode) return null;
|
||||
return (
|
||||
<Flex
|
||||
alignItems="center"
|
||||
mb={2}
|
||||
sx={{ cursor: "pointer" }}
|
||||
onClick={() => {
|
||||
if (shouldSelectAll) {
|
||||
toggleSelectionMode(false);
|
||||
} else {
|
||||
selectAll();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shouldSelectAll ? <Icon.Check /> : <Icon.CircleEmpty />}
|
||||
<Text ml={1} variant="title" color="primary">
|
||||
{shouldSelectAll ? "Unselect all" : "Select all"}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as Icon from "react-feather";
|
||||
import NavigationContainer from "./container";
|
||||
|
||||
export function createRoute(key, component, props = {}, params = {}) {
|
||||
return {
|
||||
@@ -6,13 +6,13 @@ export function createRoute(key, component, props = {}, params = {}) {
|
||||
key,
|
||||
component,
|
||||
...props,
|
||||
params
|
||||
}
|
||||
params,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createColorRoute(key, color, props = {}) {
|
||||
return createRoute(key, undefined, { icon: Icon.Circle, color, ...props });
|
||||
export function createNavigatorRoute(key, icon, navigator) {
|
||||
return createRoute(key, NavigationContainer, { icon }, { navigator });
|
||||
}
|
||||
|
||||
export function createNormalRoute(key, component, icon, props = {}) {
|
||||
|
||||
42
apps/web/src/stores/app-store.js
Normal file
42
apps/web/src/stores/app-store.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import createStore from "../common/store";
|
||||
import { db } from "../common";
|
||||
import { store as noteStore } from "./note-store";
|
||||
import { store as notebookStore } from "./notebook-store";
|
||||
import { store as trashStore } from "./trash-store";
|
||||
import { store as editorStore } from "./editor-store";
|
||||
import { store as tagStore } from "./tag-store";
|
||||
import BaseStore from "./index";
|
||||
|
||||
class AppStore extends BaseStore {
|
||||
// default state
|
||||
isSideMenuOpen = false;
|
||||
isFocusMode = false;
|
||||
colors = [];
|
||||
|
||||
refresh = async () => {
|
||||
noteStore.refresh();
|
||||
notebookStore.refresh();
|
||||
trashStore.refresh();
|
||||
tagStore.refresh();
|
||||
await editorStore.openLastSession();
|
||||
this.refreshColors();
|
||||
};
|
||||
|
||||
refreshColors = () => {
|
||||
this.set((state) => (state.colors = db.colors.all));
|
||||
};
|
||||
|
||||
toggleFocusMode = () => {
|
||||
this.set((state) => (state.isFocusMode = !state.isFocusMode));
|
||||
};
|
||||
|
||||
toggleSideMenu = () => {
|
||||
this.set((state) => (state.isSideMenuOpen = !state.isSideMenuOpen));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<AppStore>, AppStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(AppStore);
|
||||
export { useStore, store };
|
||||
204
apps/web/src/stores/editor-store.js
Normal file
204
apps/web/src/stores/editor-store.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import createStore from "../common/store";
|
||||
import { store as noteStore } from "./note-store";
|
||||
import { store as appStore } from "./app-store";
|
||||
import { store as tagStore } from "./tag-store";
|
||||
import { store as mergeStore } from "./mergestore";
|
||||
import { db } from "../common";
|
||||
import BaseStore from ".";
|
||||
import Vault from "../common/vault.js";
|
||||
import EditorNavigator from "../navigation/navigators/editornavigator";
|
||||
|
||||
const SESSION_STATES = { stale: "stale", new: "new" };
|
||||
const DEFAULT_SESSION = {
|
||||
notebook: undefined,
|
||||
state: undefined,
|
||||
isSaving: false,
|
||||
title: "",
|
||||
timeout: 0,
|
||||
id: "",
|
||||
pinned: false,
|
||||
favorite: false,
|
||||
locked: false,
|
||||
tags: [],
|
||||
context: undefined,
|
||||
colors: [],
|
||||
dateEdited: 0,
|
||||
content: {
|
||||
text: "",
|
||||
delta: {
|
||||
ops: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
class EditorStore extends BaseStore {
|
||||
session = DEFAULT_SESSION;
|
||||
arePropertiesVisible = true;
|
||||
|
||||
openLastSession = async () => {
|
||||
const id = localStorage.getItem("lastOpenedNote");
|
||||
if (!id) {
|
||||
return EditorNavigator.navigate("editor");
|
||||
}
|
||||
await this.openSession(db.notes.note(id).data);
|
||||
};
|
||||
|
||||
openSession = async (note) => {
|
||||
clearTimeout(this.get().session.timeout);
|
||||
|
||||
if (note.conflicted) {
|
||||
return await mergeStore.openConflict(note);
|
||||
} else {
|
||||
EditorNavigator.navigate("editor");
|
||||
}
|
||||
|
||||
let content = {};
|
||||
if (!note.locked) {
|
||||
content = {
|
||||
text: await db.notes.note(note).text(),
|
||||
delta: await db.notes.note(note).delta(),
|
||||
};
|
||||
} else {
|
||||
content = await Vault.openNote(note.id);
|
||||
if (!content) return;
|
||||
}
|
||||
|
||||
this.set((state) => {
|
||||
state.session = {
|
||||
...DEFAULT_SESSION,
|
||||
...note,
|
||||
content,
|
||||
state: SESSION_STATES.new,
|
||||
};
|
||||
});
|
||||
noteStore.setSelectedNote(note.id);
|
||||
|
||||
if (note.locked) return;
|
||||
saveLastOpenedNote(note.id);
|
||||
};
|
||||
|
||||
saveSession = (oldSession) => {
|
||||
this.set((state) => (state.session.isSaving = true));
|
||||
this._saveFn()(this.get().session).then(async (id) => {
|
||||
/* eslint-disable */
|
||||
storeSync: if (oldSession) {
|
||||
if (oldSession.tags.length !== this.get().session.tags.length)
|
||||
tagStore.refresh();
|
||||
|
||||
if (oldSession.colors.length !== this.get().session.colors.length)
|
||||
appStore.refreshColors();
|
||||
|
||||
if (oldSession.state !== "new" || !oldSession.context) break storeSync;
|
||||
|
||||
const { type, value } = oldSession.context;
|
||||
if (type === "topic") await db.notes.move(value, id);
|
||||
else if (type === "color") await db.notes.note(id).color(value);
|
||||
else if (type === "tag") await db.notes.note(id).tag(value);
|
||||
}
|
||||
/* eslint-enable */
|
||||
|
||||
if (!this.get().session.id) {
|
||||
noteStore.setSelectedNote(id);
|
||||
}
|
||||
|
||||
this.set((state) => {
|
||||
state.session.id = id;
|
||||
state.session.isSaving = false;
|
||||
});
|
||||
|
||||
noteStore.refresh();
|
||||
|
||||
saveLastOpenedNote(!this.get().session.locked && id);
|
||||
});
|
||||
};
|
||||
|
||||
newSession = (context = {}) => {
|
||||
EditorNavigator.navigate("editor");
|
||||
clearTimeout(this.get().session.timeout);
|
||||
this.set(function (state) {
|
||||
state.session = {
|
||||
...DEFAULT_SESSION,
|
||||
context,
|
||||
state: SESSION_STATES.new,
|
||||
};
|
||||
});
|
||||
saveLastOpenedNote();
|
||||
noteStore.setSelectedNote(0);
|
||||
};
|
||||
|
||||
setSession = (set, immediate = false) => {
|
||||
clearTimeout(this.get().session.timeout);
|
||||
const oldSession = { ...this.get().session };
|
||||
this.set((state) => {
|
||||
set(state);
|
||||
|
||||
state.session.timeout = setTimeout(
|
||||
() => {
|
||||
this.set((state) => (state.session.state = SESSION_STATES.stale));
|
||||
this.session = this.get().session;
|
||||
this.saveSession(oldSession);
|
||||
},
|
||||
immediate ? 0 : 500
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
toggleLocked = () => {
|
||||
if (this.get().session.locked) noteStore.unlock(this.get().session.id);
|
||||
else noteStore.lock(this.get().session.id);
|
||||
};
|
||||
|
||||
setColor = (color) => {
|
||||
this._setTagOrColor("color", color);
|
||||
};
|
||||
|
||||
setTag = (tag) => {
|
||||
this._setTagOrColor("tag", tag);
|
||||
};
|
||||
|
||||
toggleProperties = () => {
|
||||
this.set(
|
||||
(state) => (state.arePropertiesVisible = !state.arePropertiesVisible)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @private internal
|
||||
* @param {Boolean} isLocked
|
||||
* @returns {(note: any) => Promise<string>}
|
||||
*/
|
||||
_saveFn = () => {
|
||||
return this.get().session.locked
|
||||
? db.vault.save.bind(db.vault)
|
||||
: db.notes.add.bind(db.notes);
|
||||
};
|
||||
|
||||
_setTagOrColor(key, value) {
|
||||
const array = key === "tag" ? "tags" : "colors";
|
||||
const { [array]: arr, id } = this.get().session;
|
||||
|
||||
let note = db.notes.note(id);
|
||||
if (!note) return;
|
||||
|
||||
let index = arr.indexOf(value);
|
||||
if (index > -1) {
|
||||
note[`un${key}`](value).then(() => {
|
||||
this.setSession((state) => state.session[array].splice(index, 1), true);
|
||||
});
|
||||
} else {
|
||||
note[key](value).then(() => {
|
||||
this.setSession((state) => state.session[array].push(value), true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveLastOpenedNote(id) {
|
||||
if (!id) return localStorage.removeItem("lastOpenedNote");
|
||||
localStorage.setItem("lastOpenedNote", id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<EditorStore>, EditorStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(EditorStore);
|
||||
export { useStore, store, SESSION_STATES };
|
||||
13
apps/web/src/stores/index.js
Normal file
13
apps/web/src/stores/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
class BaseStore {
|
||||
static new(set, get) {
|
||||
return new this(set, get);
|
||||
}
|
||||
|
||||
constructor(set, get) {
|
||||
this.set = set;
|
||||
this.get = get;
|
||||
this.id = Math.random();
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseStore;
|
||||
60
apps/web/src/stores/mergestore.js
Normal file
60
apps/web/src/stores/mergestore.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import createStore from "../common/store";
|
||||
import { db } from "../common";
|
||||
import { store as noteStore } from "./note-store";
|
||||
import { store as editorStore } from "./editor-store";
|
||||
import EditorNavigator from "../navigation/navigators/editornavigator";
|
||||
import BaseStore from "./index";
|
||||
|
||||
class MergeStore extends BaseStore {
|
||||
conflictedNote;
|
||||
localDelta;
|
||||
remoteDelta;
|
||||
|
||||
openConflict = async (note) => {
|
||||
noteStore.setSelectedNote(note.id);
|
||||
const delta = await db.delta.raw(note.content.delta);
|
||||
this.set((state) => {
|
||||
state.conflictedNote = note;
|
||||
state.localDelta = { ...delta, conflicted: false };
|
||||
state.remoteDelta = delta.conflicted;
|
||||
});
|
||||
EditorNavigator.navigate("split");
|
||||
};
|
||||
|
||||
resolveConflict = async (selectedContent, otherContent) => {
|
||||
const note = this.get().conflictedNote;
|
||||
selectedContent.delta = {
|
||||
data: { ops: selectedContent.delta },
|
||||
resolved: true,
|
||||
};
|
||||
await db.notes.add({
|
||||
id: note.id,
|
||||
content: selectedContent,
|
||||
conflicted: false,
|
||||
});
|
||||
if (otherContent) {
|
||||
otherContent.delta = {
|
||||
data: { ops: otherContent.delta },
|
||||
};
|
||||
await db.notes.add({
|
||||
...note,
|
||||
content: otherContent,
|
||||
id: undefined,
|
||||
dateCreated: undefined,
|
||||
dateEdited: undefined,
|
||||
title: note.title + " (DUPLICATE)",
|
||||
});
|
||||
}
|
||||
this.set((state) => {
|
||||
state.conflictedNote = undefined;
|
||||
state.localDelta = undefined;
|
||||
state.remoteDelta = undefined;
|
||||
});
|
||||
noteStore.refresh();
|
||||
noteStore.setSelectedNote(note.id);
|
||||
await editorStore.openSession(note.id);
|
||||
};
|
||||
}
|
||||
|
||||
const [useStore, store] = createStore(MergeStore);
|
||||
export { useStore, store };
|
||||
126
apps/web/src/stores/note-store.js
Normal file
126
apps/web/src/stores/note-store.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { db } from "../common/index";
|
||||
import createStore from "../common/store";
|
||||
import { store as editorStore } from "./editor-store";
|
||||
import Vault from "../common/vault";
|
||||
import BaseStore from ".";
|
||||
|
||||
class NoteStore extends BaseStore {
|
||||
notes = {
|
||||
items: [],
|
||||
groupCounts: [],
|
||||
groups: [],
|
||||
};
|
||||
context = undefined;
|
||||
selectedNote = 0;
|
||||
|
||||
setSelectedNote = (id) => {
|
||||
this.set((state) => (state.selectedNote = id));
|
||||
};
|
||||
|
||||
refresh = () => {
|
||||
this.refreshContext();
|
||||
this.set((state) => (state.notes = db.notes.group(undefined, true)));
|
||||
};
|
||||
|
||||
refreshContext = () => {
|
||||
const context = this.get().context;
|
||||
if (!context) return;
|
||||
this.setContext(context);
|
||||
};
|
||||
|
||||
setContext = (context) => {
|
||||
let notes = [];
|
||||
switch (context.type) {
|
||||
case "tag":
|
||||
notes = db.notes.tagged(context.value);
|
||||
break;
|
||||
case "color":
|
||||
notes = db.notes.colored(context.value);
|
||||
break;
|
||||
case "topic":
|
||||
notes = db.notebooks
|
||||
.notebook(context.value.id)
|
||||
.topics.topic(context.value.topic).all;
|
||||
break;
|
||||
case "favorites":
|
||||
notes = db.notes.favorites;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
this.set((state) => (state.context = { ...context, notes }));
|
||||
};
|
||||
|
||||
delete = async (id) => {
|
||||
await db.notes.delete(id);
|
||||
this.refreshContext();
|
||||
this.refresh();
|
||||
const { session, newSession } = editorStore.get();
|
||||
if (session.id === id) {
|
||||
newSession();
|
||||
}
|
||||
};
|
||||
|
||||
pin = async (note) => {
|
||||
await db.notes.note(note).pin();
|
||||
this.refresh();
|
||||
this._syncEditor(note.id, "pinned");
|
||||
};
|
||||
|
||||
favorite = async (note) => {
|
||||
await db.notes.note(note).favorite();
|
||||
this.refreshContext.defer();
|
||||
this._setValue(note.id, "favorite", !note.favorite);
|
||||
};
|
||||
|
||||
unlock = async (id) => {
|
||||
await Vault.unlockNote(id, () => this._setValue(id, "locked", false));
|
||||
};
|
||||
|
||||
lock = async (id) => {
|
||||
await Vault.lockNote(id, () => this._setValue(id, "locked", true));
|
||||
};
|
||||
|
||||
setColor = async (id, color) => {
|
||||
const note = db.notes.note(id);
|
||||
if (!note) return;
|
||||
let index = note.data.colors.indexOf(color);
|
||||
if (index > -1) {
|
||||
await note.uncolor(color);
|
||||
} else {
|
||||
await note.color(color);
|
||||
}
|
||||
this._setValue(id, "colors", db.notes.note(id).data.colors);
|
||||
};
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_setValue = (noteId, prop, value) => {
|
||||
this.set((state) => {
|
||||
const { context, notes } = state;
|
||||
const arr = !context ? notes.items : context.notes;
|
||||
let index = arr.findIndex((note) => note.id === noteId);
|
||||
if (index < 0) return;
|
||||
|
||||
arr[index][prop] = value;
|
||||
this._syncEditor(noteId, prop, value);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_syncEditor = (noteId, action, value) => {
|
||||
const { session, setSession } = editorStore.get();
|
||||
if (session.id === noteId) {
|
||||
setSession((state) => (state.session[action] = value));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<NoteStore>, NoteStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(NoteStore);
|
||||
export { useStore, store };
|
||||
45
apps/web/src/stores/notebook-store.js
Normal file
45
apps/web/src/stores/notebook-store.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { db } from "../common/index";
|
||||
import createStore from "../common/store";
|
||||
import { store as trashStore } from "./trash-store";
|
||||
import BaseStore from "./index";
|
||||
|
||||
class NotebookStore extends BaseStore {
|
||||
notebooks = [];
|
||||
selectedNotebookTopics = [];
|
||||
|
||||
refresh = () => {
|
||||
this.set(state => (state.notebooks = db.notebooks.all));
|
||||
};
|
||||
|
||||
add = async nb => {
|
||||
let notebook = await db.notebooks.add(nb);
|
||||
if (notebook) {
|
||||
this.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
delete = async (id, index) => {
|
||||
await db.notebooks.delete(id);
|
||||
this.set(state => {
|
||||
state.notebooks.splice(index, 1);
|
||||
trashStore.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
pin = async notebook => {
|
||||
await db.notebooks.notebook(notebook).pin();
|
||||
this.refresh();
|
||||
};
|
||||
|
||||
setSelectedNotebookTopics = id => {
|
||||
this.set(state => {
|
||||
state.selectedNotebookTopics = db.notebooks.notebook(id).topics.all;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<NotebookStore>, NotebookStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(NotebookStore);
|
||||
export { useStore, store };
|
||||
30
apps/web/src/stores/search-store.js
Normal file
30
apps/web/src/stores/search-store.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import createStore from "../common/store";
|
||||
import { db } from "../common";
|
||||
import BaseStore from "./index";
|
||||
|
||||
class SearchStore extends BaseStore {
|
||||
type = "";
|
||||
items = [];
|
||||
item = undefined;
|
||||
results = [];
|
||||
|
||||
setSearchContext = (context) => {
|
||||
this.set((state) => {
|
||||
state.type = context.type;
|
||||
state.items = context.items;
|
||||
state.item = context.item;
|
||||
});
|
||||
};
|
||||
|
||||
search = async (query) => {
|
||||
const { items, type } = this.get();
|
||||
const results = await db.lookup[type](items, query);
|
||||
this.set((state) => (state.results = results));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<SearchStore>, SearchStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(SearchStore);
|
||||
export { useStore, store };
|
||||
47
apps/web/src/stores/selection-store.js
Normal file
47
apps/web/src/stores/selection-store.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import createStore from "../common/store";
|
||||
import BaseStore from "./index";
|
||||
|
||||
class SelectionStore extends BaseStore {
|
||||
selectedItems = [];
|
||||
shouldSelectAll = false;
|
||||
isSelectionMode = false;
|
||||
|
||||
toggleSelectionMode = (toggleState) => {
|
||||
this.set((state) => {
|
||||
const isSelectionMode =
|
||||
toggleState !== undefined ? toggleState : !state.isSelectionMode;
|
||||
state.isSelectionMode = isSelectionMode;
|
||||
state.shouldSelectAll = false;
|
||||
state.selectedItems = [];
|
||||
});
|
||||
};
|
||||
|
||||
selectItem = (item) => {
|
||||
const index = this.get().selectedItems.findIndex((v) => item.id === v.id);
|
||||
this.set((state) => {
|
||||
if (index >= 0) {
|
||||
state.selectedItems.splice(index, 1);
|
||||
} else {
|
||||
state.selectedItems.push(item);
|
||||
}
|
||||
});
|
||||
if (this.get().selectedItems.length <= 0) {
|
||||
this.toggleSelectionMode();
|
||||
}
|
||||
};
|
||||
|
||||
setSelectedItems = (items) => {
|
||||
this.set((state) => (state.selectedItems = items));
|
||||
};
|
||||
|
||||
selectAll = () => {
|
||||
if (!this.get().isSelectionMode) return;
|
||||
this.set((state) => (state.shouldSelectAll = true));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<SelectionStore>, SelectionStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(SelectionStore);
|
||||
export { useStore, store };
|
||||
17
apps/web/src/stores/tag-store.js
Normal file
17
apps/web/src/stores/tag-store.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import createStore from "../common/store";
|
||||
import { db } from "../common";
|
||||
import BaseStore from "./index";
|
||||
|
||||
class TagStore extends BaseStore {
|
||||
tags = [];
|
||||
|
||||
refresh = () => {
|
||||
this.set(state => (state.tags = db.tags.all));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<TagStore>, TagStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(TagStore);
|
||||
export { useStore, store };
|
||||
29
apps/web/src/stores/theme-store.js
Normal file
29
apps/web/src/stores/theme-store.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import createStore from "../common/store";
|
||||
import BaseStore from "./index";
|
||||
import Config from "../utils/config";
|
||||
|
||||
class ThemeStore extends BaseStore {
|
||||
theme = Config.get("theme", "light");
|
||||
accent = Config.get("accent", "#0560ff");
|
||||
|
||||
setTheme = (theme) => {
|
||||
this.set((state) => (state.theme = theme));
|
||||
Config.set("theme", theme);
|
||||
};
|
||||
|
||||
toggleNightMode = () => {
|
||||
const theme = this.get().theme;
|
||||
this.setTheme(theme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
setAccent = (accent) => {
|
||||
this.set((state) => (state.accent = accent));
|
||||
Config.set("accent", accent);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<ThemeStore>, ThemeStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(ThemeStore);
|
||||
export { useStore, store };
|
||||
37
apps/web/src/stores/trash-store.js
Normal file
37
apps/web/src/stores/trash-store.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { db } from "../common/index";
|
||||
import createStore from "../common/store";
|
||||
import BaseStore from "./index";
|
||||
|
||||
class TrashStore extends BaseStore {
|
||||
trash = [];
|
||||
|
||||
refresh = () => {
|
||||
this.set(state => (state.trash = db.trash.all));
|
||||
};
|
||||
|
||||
delete = (id, index) => {
|
||||
return db.trash.delete(id).then(() => {
|
||||
this.set(state => {
|
||||
state.trash.splice(index, 1);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
restore = (id, index) => {
|
||||
return db.trash.restore(id).then(() => {
|
||||
this.set(state => state.trash.splice(index, 1));
|
||||
});
|
||||
};
|
||||
|
||||
clear = () => {
|
||||
return db.trash.clear().then(() => {
|
||||
this.set(state => (state.trash = []));
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<TrashStore>, TrashStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(TrashStore);
|
||||
export { useStore, store };
|
||||
81
apps/web/src/stores/user-store.js
Normal file
81
apps/web/src/stores/user-store.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import createStore from "../common/store";
|
||||
import { db } from "../common";
|
||||
import { store as appStore } from "./app-store";
|
||||
import BaseStore from "./index";
|
||||
import RootNavigator from "../navigation/navigators/rootnavigator";
|
||||
|
||||
class UserStore extends BaseStore {
|
||||
isLoggedIn = false;
|
||||
isLoggingIn = false;
|
||||
isSigningIn = false;
|
||||
isSyncing = false;
|
||||
user = undefined;
|
||||
|
||||
init = () => {
|
||||
return db.user.get().then((user) => {
|
||||
if (!user) return false;
|
||||
this.set((state) => {
|
||||
state.user = user;
|
||||
state.isLoggedIn = true;
|
||||
});
|
||||
this.sync();
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
login = (form) => {
|
||||
this.set((state) => (state.isLoggingIn = true));
|
||||
return db.user
|
||||
.login(form.username, form.password)
|
||||
.then(() => {
|
||||
return this.init();
|
||||
})
|
||||
.finally(() => {
|
||||
this.set((state) => (state.isLoggingIn = false));
|
||||
});
|
||||
};
|
||||
|
||||
signup = (form) => {
|
||||
this.set((state) => (state.isSigningIn = true));
|
||||
return db.user
|
||||
.signup(form.username, form.email, form.password)
|
||||
.then(() => {
|
||||
return this.init();
|
||||
})
|
||||
.finally(() => {
|
||||
this.set((state) => (state.isSigningIn = false));
|
||||
});
|
||||
};
|
||||
|
||||
sync = () => {
|
||||
this.set((state) => (state.isSyncing = true));
|
||||
db.sync()
|
||||
.then(() => {
|
||||
appStore.refresh();
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (err.code === "MERGE_CONFLICT") await appStore.refresh();
|
||||
else console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
this.set((state) => (state.isSyncing = false));
|
||||
});
|
||||
};
|
||||
|
||||
logout = () => {
|
||||
return db.user.logout().then(async () => {
|
||||
this.set((state) => {
|
||||
state.user = undefined;
|
||||
state.isLoggedIn = false;
|
||||
});
|
||||
await appStore.refresh();
|
||||
RootNavigator.navigate("home");
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {[import("zustand").UseStore<UserStore>, UserStore]}
|
||||
*/
|
||||
const [useStore, store] = createStore(UserStore);
|
||||
export { useStore, store };
|
||||
13
apps/web/src/theme/accents.js
Normal file
13
apps/web/src/theme/accents.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const accents = [
|
||||
{ label: "red", code: "#ed2d37" },
|
||||
{ label: "orange", code: "#ec6e05" },
|
||||
{ label: "yellow", code: "yellow" },
|
||||
{ label: "green", code: "green" },
|
||||
{ label: "blue", code: "blue" },
|
||||
{ label: "purple", code: "purple" },
|
||||
{ label: "gray", code: "gray" },
|
||||
{ label: "lightblue", code: "#46F0F0" },
|
||||
{ label: "indigo", code: "#F032E6" },
|
||||
{ label: "lightpink", code: "#FABEBE" }
|
||||
];
|
||||
export default accents;
|
||||
23
apps/web/src/theme/colorscheme/dark.js
Normal file
23
apps/web/src/theme/colorscheme/dark.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { hexToRGB } from "../../utils/color";
|
||||
import StaticColorSchemeFactory from "./static";
|
||||
|
||||
class DarkColorSchemeFactory {
|
||||
constructor(accent) {
|
||||
return {
|
||||
primary: accent,
|
||||
placeholder: hexToRGB("#ffffff", 0.6),
|
||||
background: "#1f1f1f",
|
||||
accent: "#000",
|
||||
bgSecondary: "#2b2b2b",
|
||||
border: "#2b2b2b",
|
||||
hover: "#3b3b3b",
|
||||
fontSecondary: "#000",
|
||||
text: "#ffffff",
|
||||
overlay: "rgba(255, 255, 255, 0.5)",
|
||||
secondary: "black",
|
||||
icon: "#dbdbdb",
|
||||
...new StaticColorSchemeFactory(accent)
|
||||
};
|
||||
}
|
||||
}
|
||||
export default DarkColorSchemeFactory;
|
||||
14
apps/web/src/theme/colorscheme/index.js
Normal file
14
apps/web/src/theme/colorscheme/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import DarkColorSchemeFactory from "./dark";
|
||||
import LightColorSchemeFactory from "./light";
|
||||
|
||||
const colorSchemes = {
|
||||
dark: DarkColorSchemeFactory,
|
||||
light: LightColorSchemeFactory
|
||||
};
|
||||
|
||||
class ColorSchemeFactory {
|
||||
constructor(theme, accent) {
|
||||
return new colorSchemes[theme](accent);
|
||||
}
|
||||
}
|
||||
export default ColorSchemeFactory;
|
||||
23
apps/web/src/theme/colorscheme/light.js
Normal file
23
apps/web/src/theme/colorscheme/light.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { hexToRGB } from "../../utils/color";
|
||||
import StaticColorSchemeFactory from "./static";
|
||||
|
||||
class LightColorSchemeFactory {
|
||||
constructor(accent) {
|
||||
return {
|
||||
primary: accent,
|
||||
background: "white",
|
||||
accent: "white",
|
||||
bgSecondary: "#f0f0f0",
|
||||
border: "#e0e0e0",
|
||||
hover: "#e0e0e0",
|
||||
fontSecondary: "white",
|
||||
text: "#000000",
|
||||
overlay: "rgba(0, 0, 0, 0.1)",
|
||||
secondary: "white",
|
||||
icon: "#3b3b3b",
|
||||
placeholder: hexToRGB("#000000", 0.6),
|
||||
...new StaticColorSchemeFactory(accent)
|
||||
};
|
||||
}
|
||||
}
|
||||
export default LightColorSchemeFactory;
|
||||
16
apps/web/src/theme/colorscheme/static.js
Normal file
16
apps/web/src/theme/colorscheme/static.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { hexToRGB } from "../../utils/color";
|
||||
|
||||
class StaticColorSchemeFactory {
|
||||
constructor(accent) {
|
||||
return {
|
||||
shade: hexToRGB(accent, 0.1),
|
||||
dimPrimary: hexToRGB(accent, 0.7),
|
||||
fontTertiary: "gray",
|
||||
transparent: "transparent",
|
||||
static: "white",
|
||||
error: "#E53935",
|
||||
favorite: "#ffd700"
|
||||
};
|
||||
}
|
||||
}
|
||||
export default StaticColorSchemeFactory;
|
||||
14
apps/web/src/theme/font/fontsize.js
Normal file
14
apps/web/src/theme/font/fontsize.js
Normal file
@@ -0,0 +1,14 @@
|
||||
class FontSizeFactory {
|
||||
constructor(scaleFactor) {
|
||||
return {
|
||||
heading: 28 * scaleFactor,
|
||||
input: 16 * scaleFactor,
|
||||
title: 18 * scaleFactor,
|
||||
subtitle: 16 * scaleFactor,
|
||||
body: 16 * scaleFactor,
|
||||
menu: 14 * scaleFactor,
|
||||
subBody: 11 * scaleFactor
|
||||
};
|
||||
}
|
||||
}
|
||||
export default FontSizeFactory;
|
||||
19
apps/web/src/theme/font/index.js
Normal file
19
apps/web/src/theme/font/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import FontSizeFactory from "./fontsize";
|
||||
|
||||
class FontFactory {
|
||||
constructor(scale) {
|
||||
return {
|
||||
fontSizes: new FontSizeFactory(scale),
|
||||
fontWeights: {
|
||||
body: 400,
|
||||
heading: 400,
|
||||
bold: 400
|
||||
},
|
||||
fonts: {
|
||||
body: "DM Sans, sans-serif",
|
||||
heading: `"DM Serif Text", serif`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
export default FontFactory;
|
||||
25
apps/web/src/theme/index.js
Normal file
25
apps/web/src/theme/index.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import ColorSchemeFactory from "./colorscheme";
|
||||
import VariantsFactory from "./variants";
|
||||
import FontFactory from "./font";
|
||||
import TransformerFactory from "./transformer";
|
||||
|
||||
class ThemeFactory {
|
||||
transform(type, theme) {
|
||||
const factory = new TransformerFactory();
|
||||
return factory.construct(type, theme);
|
||||
}
|
||||
|
||||
construct(config) {
|
||||
return {
|
||||
breakpoints: ["480px", "1000px", "1000px"],
|
||||
space: [0, 5, 10, 15, 20, 25, 30, 35],
|
||||
sizes: { full: "100%", half: "50%" },
|
||||
radii: { none: 0, default: 5 },
|
||||
colors: new ColorSchemeFactory(config.theme, config.accent),
|
||||
...new FontFactory(config.scale),
|
||||
...new VariantsFactory()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default ThemeFactory;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user