Merge remote-tracking branch 'origin/master' into responsive

This commit is contained in:
thecodrr
2020-04-21 13:12:55 +05:00
137 changed files with 8471 additions and 5729 deletions

21
apps/web/.github/workflows/build.yml vendored Normal file
View 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
View 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
View 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
View File

@@ -21,3 +21,5 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.now

View File

@@ -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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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.

View File

@@ -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",

View File

@@ -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.

View File

@@ -1,2 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow: /

33
apps/web/scripts/ci.sh Executable file
View 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

View File

@@ -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;

View File

@@ -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: {} };

View 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,
};

View 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;

View 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;

View 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;

View 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)
};

View File

@@ -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;

View File

@@ -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;

View 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;

View 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)}
/>
));
}

View 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'");
}

View File

@@ -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'");
};

View 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()} />);
};

View 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)}
/>
));
}

View 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)}
/>
));
}

View 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()} />);
}

View 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);
}}
/>
));
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,13 +0,0 @@
.dropdown {
display: inline-block;
}
.dropdown__content {
display: none;
position: absolute;
}
.dropdown--active .dropdown__content {
display: block;
}

View 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;

View File

@@ -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;
}

View 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;

View 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;

View File

@@ -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;

View File

@@ -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

View 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} />;
}
}

View 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;

View File

@@ -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);

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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>
);

View 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;

View 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;

View File

@@ -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)
);
});

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View 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)];
}

View File

@@ -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);

View 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;

View File

@@ -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;

View File

@@ -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>
);

View 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;

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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";

View 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;

View File

@@ -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;
}

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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>
);
}

View File

@@ -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 = {}) {

View 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 };

View 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 };

View 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;

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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