theme: add theme builder app

This commit is contained in:
ammarahm-ed
2023-08-08 10:55:10 +05:00
committed by Abdullah Atta
parent ed6d125391
commit 380bfba776
21 changed files with 51817 additions and 3 deletions

View File

@@ -33,7 +33,8 @@ const SCOPES = [
"misc",
"common",
"global",
"docs"
"docs",
"theme-builder"
];
module.exports = {

33
apps/theme-builder/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.now
__diff_output__
dist
public/workbox
scripts/secrets
test-results
.swc

View File

@@ -0,0 +1,56 @@
<p align="center">
<img style="align:center;" src="/resources/screenshots/web.png" alt="Notesnook theme builder screenshot" width="600" />
</p>
<h1 align="center">Notesnook Theme Builder</h1>
<h3 align="center">The theme builder app is built using React, Typescript & Javascript.</h3>
<p align="center">
<a href="https://app.notesnook.com/">Try it out!</a> | <a href="#developer-guide">Developer guide</a> | <a href="#build-instructions">How to build?</a> | <a href="../desktop/">Desktop app</a>
</p>
## Getting started
## Build instructions
> **Before you start, it is recommended that you read [the contributing guidelines](/CONTRIBUTING.md).**
### Setting up the development environment
Requirements:
1. [Node.js](https://nodejs.org/en/download/)
2. [git](https://git-scm.com/downloads)
3. NPM (not yarn or pnpm)
Before you can do anything, you'll need to [install Node.js](https://nodejs.org/en/download/) on your system.
Once you have completed the setup, the first step is to `clone` the monorepo:
```bash
git clone https://github.com/streetwriters/notesnook.git
# change directory
cd notesnook
```
Once you are inside the `./notesnook` directory, run the preparation step:
```bash
# this might take a while to complete
npm install
```
Now you can finally start the web app:
```bash
npm run start:theme-builder
```
If you'd like to build in production mode:
```bash
npm run build:theme-builder
# serve the app locally
npx serve apps/theme-builder/build
```

49293
apps/theme-builder/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
{
"name": "@notesnook/theme-builder",
"description": "Your private note taking space",
"version": "1.0.0",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",
"repository": "https://github.com/streetwriters/notesnook",
"license": "GPL-3.0-or-later",
"dependencies": {
"@aws-sdk/util-base64-browser": "^3.208.0",
"@brixtol/currency-symbols": "^1.1.1",
"@dnd-kit/core": "^6.0.5",
"@dnd-kit/modifiers": "^6.0.0",
"@dnd-kit/sortable": "^7.0.1",
"@emotion/react": "11.11.1",
"@mdi/js": "^7.2.96",
"@mdi/react": "^1.6.1",
"@notesnook-importer/core": "^1.7.1",
"@notesnook/common": "file:../../packages/common",
"@notesnook/core": "file:../../packages/core",
"@notesnook/crypto": "file:../../packages/crypto",
"@notesnook/crypto-worker": "file:../../packages/crypto-worker",
"@notesnook/desktop": "file:../desktop",
"@notesnook/editor": "file:../../packages/editor",
"@notesnook/logger": "file:../../packages/logger",
"@notesnook/streamable-fs": "file:../../packages/streamable-fs",
"@notesnook/theme": "file:../../packages/theme",
"@notesnook/themes-server": "file:../../servers/themes",
"@notesnook/ui": "file:../../packages/ui",
"@notesnook/web": "file:../web",
"@notesnook/web-clipper": "file:../../extensions/web-clipper",
"@react-pdf-viewer/core": "^3.12.0",
"@react-pdf-viewer/toolbar": "^3.12.0",
"@tanstack/react-query": "^4.29.19",
"@theme-ui/color": "^0.14.7",
"@theme-ui/components": "^0.14.7",
"@theme-ui/core": "^0.14.7",
"@trpc/client": "10.31.0",
"@trpc/react-query": "10.31.0",
"allotment": "^1.19.0",
"axios": "^1.3.4",
"clipboard-polyfill": "4.0.0",
"comlink": "^4.3.1",
"cronosjs": "^1.7.1",
"dayjs": "1.11.9",
"electron-trpc": "0.5.2",
"event-source-polyfill": "^1.0.25",
"fflate": "^0.8.0",
"file-saver": "^2.0.5",
"framer-motion": "^6.5.1",
"hash-wasm": "^4.9.0",
"hotkeys-js": "^3.8.3",
"immer": "^9.0.6",
"katex": "0.16.2",
"mac-scrollbar": "^0.10.3",
"marked": "^4.1.0",
"pdfjs-dist": "3.6.172",
"phone": "^3.1.14",
"platform": "^1.3.6",
"qclone": "^1.2.0",
"react-dropzone": "^11.4.2",
"react-hot-toast": "^2.2.0",
"react-loading-skeleton": "^3.1.0",
"react-modal": "3.13.1",
"react-qrcode-logo": "^2.2.1",
"react-scroll-sync": "^0.9.0",
"react-virtuoso": "^4.4.2",
"timeago.js": "4.0.2",
"tinycolor2": "^1.6.0",
"w3c-keyname": "^2.2.6",
"web-streams-polyfill": "^3.1.1",
"wouter": "2.7.3",
"zustand": "4.3.9"
},
"devDependencies": {
"@babel/core": "^7.22.5",
"@playwright/test": "^1.36.2",
"@trpc/server": "10.31.0",
"@types/babel__core": "^7.20.1",
"@types/file-saver": "^2.0.5",
"@types/marked": "^4.0.7",
"@types/node-fetch": "^2.5.10",
"@types/platform": "^1.3.4",
"@types/react": "17.0.2",
"@types/react-dom": "17.0.2",
"@types/react-modal": "3.13.1",
"@types/tinycolor2": "^1.4.3",
"@vitejs/plugin-react-swc": "^3.3.2",
"autoprefixer": "^10.4.14",
"buffer": "^6.0.3",
"chalk": "^4.1.0",
"cross-env": "^7.0.3",
"dotenv": "^10.0.0",
"file-loader": "^6.2.0",
"find-process": "^1.4.4",
"happy-dom": "^8.9.0",
"ip": "^1.1.8",
"lorem-ipsum": "^2.0.4",
"otplib": "^12.0.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"rollup": "^3.27.0",
"rollup-plugin-visualizer": "^5.9.2",
"swc-plugin-react-remove-properties": "^0.1.4",
"vite": "^4.3.9",
"vite-plugin-env-compatible": "^1.1.1",
"vite-plugin-pwa": "^0.16.3",
"vite-plugin-svgr": "^3.2.0",
"vitest": "^0.32.0",
"workbox-core": "^7.0.0",
"workbox-expiration": "^7.0.0",
"workbox-precaching": "^7.0.0",
"workbox-routing": "^7.0.0",
"workbox-strategies": "^7.0.0"
},
"overrides": {
"react@>17": "17.0.2",
"react-dom@>17": "17.0.2",
"@types/react@>17": "17.0.2",
"@types/react-dom@>17": "17.0.2",
"@emotion/react@>11": "11.10.5",
"@theme-ui/components@>0": "0.14.7",
"@theme-ui/core@>0": "0.14.7"
},
"scripts": {
"start": "cross-env PLATFORM=web vite",
"build": "cross-env PLATFORM=web vite build"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all",
"ie >= 9"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version",
"last 3 ie version",
"last 4 edge version"
]
},
"author": {
"name": "Streetwriters (Private) Limited",
"email": "support@streetwriters.co",
"url": "https://streetwriters.co"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

View File

@@ -0,0 +1,217 @@
:root {
--sash-size: 10px;
--sash-hover-size: 4px;
}
.sash-module_sash__K-9lB.sash-module_hover__80W6I:before,
.sash-module_sash__K-9lB.sash-module_active__bJspD:before {
background: var(--accent);
}
.allotment-module_splitView__L-yRc.allotment-module_separatorBorder__x-rDS
> .allotment-module_splitViewContainer__rQnVa
> .allotment-module_splitViewView__MGZ6O:not(:first-child)::before {
background-color: var(--border);
}
/* open-sans-regular - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-display: swap;
font-weight: 400;
src: local(""),
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff2")
format("woff2"),
/* Super Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff")
format("woff"),
/* Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-regular.ttf")
format("truetype");
}
/* open-sans-600 - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 600;
font-display: swap;
src: local(""),
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600.woff2")
format("woff2"),
/* Super Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600.woff")
format("woff"),
/* Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600.ttf")
format("truetype");
}
/* open-sans-700 - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: normal;
font-weight: 700;
font-display: swap;
src: local(""),
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700.woff2")
format("woff2"),
/* Super Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700.woff")
format("woff"),
/* Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700.ttf")
format("truetype");
}
/* open-sans-italic - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 400;
font-display: swap;
src: local(""),
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff2")
format("woff2"),
/* Super Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff")
format("woff"),
/* Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-italic.ttf")
format("truetype");
}
/* open-sans-600italic - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 600;
font-display: swap;
src: local(""),
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600italic.woff2")
format("woff2"),
/* Super Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600italic.woff")
format("woff"),
/* Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-600italic.ttf")
format("truetype");
}
/* open-sans-700italic - vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic */
@font-face {
font-family: "Open Sans";
font-style: italic;
font-weight: 700;
font-display: swap;
src: local(""),
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff2")
format("woff2"),
/* Super Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff")
format("woff"),
/* Modern Browsers */
url("./assets/fonts/open-sans-v34-vietnamese_latin-ext_latin_hebrew_greek-ext_greek_cyrillic-ext_cyrillic-700italic.ttf")
format("truetype");
}
.rpv-core__text-layer,
.rpv-core__text-layer *,
.selectable,
.selectable *,
input,
textarea,
[contenteditable="true"],
[contenteditable="true"] * {
-webkit-touch-callout: initial;
-webkit-user-select: text;
-khtml-user-select: text;
-moz-user-select: text;
-ms-user-select: initial;
user-select: text;
}
* {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
outline: none;
-webkit-tap-highlight-color: transparent;
}
.rpv-core__text-layer-text::selection {
background-color: var(--textSelection) !important;
color: transparent;
}
.rpv-core__text-layer-text::-moz-selection {
/* Code for Firefox */
background-color: var(--textSelection) !important;
color: transparent;
}
::-moz-selection {
/* Code for Firefox */
background-color: var(--textSelection);
color: var(--paragraph);
}
::selection {
background-color: var(--textSelection);
color: var(--paragraph);
}
*::-moz-focus-inner {
border: 0;
}
.ct-toast {
padding: 0px 10px !important;
background-color: var(--background) !important;
}
.route {
display: flex;
flex-direction: column;
flex: 1;
opacity: 1;
}
.middle-pane {
overflow: hidden;
display: flex;
}
.editor-pane {
overflow: hidden;
display: flex;
flex-direction: column;
}
.nav-pane {
display: flex;
flex-direction: column;
flex: 1;
}
.pane::before {
width: 1px !important;
}
.route#settings,
#mainRouteContainer {
overflow: hidden;
}
.ms-track:hover,
.ms-track:active {
background: var(--background-secondary) !important;
border-left: 1px solid var(--border-secondary) !important;
}
.ms-track {
border-width: 0px !important;
}
.ms-active .ms-thumb {
filter: brightness(80%);
}
.ms-thumb {
background: var(--paragraph-secondary) !important;
}

View File

@@ -0,0 +1,308 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React, { useState, Suspense, useRef } from "react";
import { Box, Flex } from "@theme-ui/components";
import {
BaseThemeProvider,
ScopedThemeProvider
} from "@notesnook/web/src/components/theme-provider";
import useMobile from "@notesnook/web/src/hooks/use-mobile";
import useTablet from "@notesnook/web/src/hooks/use-tablet";
import useDatabase from "@notesnook/web/src/hooks/use-database";
import { Allotment, AllotmentHandle, LayoutPriority } from "allotment";
import { useStore } from "@notesnook/web/src/stores/app-store";
import { Toaster } from "react-hot-toast";
import { ViewLoader } from "@notesnook/web/src/components/loaders/view-loader";
import NavigationMenu from "@notesnook/web/src/components/navigation-menu";
import StatusBar from "@notesnook/web/src/components/status-bar";
import { EditorLoader } from "@notesnook/web/src/components/loaders/editor-loader";
import { FlexScrollContainer } from "@notesnook/web/src/components/scroll-container";
import CachedRouter from "@notesnook/web/src/components/cached-router";
import { WebExtensionRelay } from "@notesnook/web/src/utils/web-extension-relay";
import { usePersistentState } from "@notesnook/web/src/hooks/use-persistent-state";
import ThemeBuilder from "./components/theme-builder";
new WebExtensionRelay();
const GlobalMenuWrapper = React.lazy(
() => import("@notesnook/web/src/components/global-menu-wrapper")
);
const AppEffects = React.lazy(() => import("@notesnook/web/src/app-effects"));
const MobileAppEffects = React.lazy(
() => import("@notesnook/web/src/app-effects.mobile")
);
const HashRouter = React.lazy(
() => import("@notesnook/web/src/components/hash-router")
);
function App() {
const isMobile = useMobile();
const [show, setShow] = useState(true);
const [isAppLoaded] = useDatabase();
return (
<BaseThemeProvider sx={{ height: "100%" }} addGlobalStyles>
{isAppLoaded && (
<Suspense fallback={<div style={{ display: "none" }} />}>
<div id="menu-wrapper">
<GlobalMenuWrapper />
</div>
<AppEffects setShow={setShow} />
{isMobile && (
<MobileAppEffects
sliderId="slider"
overlayId="overlay"
setShow={setShow}
/>
)}
</Suspense>
)}
<Flex
id="app"
bg="background"
sx={{ overflow: "hidden", flexDirection: "column", height: "100%" }}
>
{isMobile ? (
<MobileAppContents isAppLoaded={isAppLoaded} />
) : (
<DesktopAppContents
isAppLoaded={isAppLoaded}
setShow={setShow}
show={show}
/>
)}
<Toaster containerClassName="toasts-container" />
</Flex>
</BaseThemeProvider>
);
}
export default App;
type SuspenseLoaderProps<TComponent extends React.JSXElementConstructor<any>> =
{
condition: boolean;
props?: React.ComponentProps<TComponent>;
component: TComponent;
fallback: JSX.Element;
};
function SuspenseLoader<TComponent extends React.JSXElementConstructor<any>>({
condition,
props,
component,
fallback
}: SuspenseLoaderProps<TComponent>) {
if (!condition) return fallback;
const Component = component as (
props: any
) => React.ReactComponentElement<any, any>;
return (
<Suspense fallback={IS_DESKTOP_APP ? null : fallback}>
<Component {...props} />
</Suspense>
);
}
type DesktopAppContentsProps = {
isAppLoaded: boolean;
show: boolean;
setShow: (show: boolean) => void;
};
function DesktopAppContents({
isAppLoaded,
show,
setShow
}: DesktopAppContentsProps) {
const isFocusMode = useStore((store: any) => store.isFocusMode);
const isTablet = useTablet();
const [paneSizes, setPaneSizes] = usePersistentState("paneSizes", [
isTablet ? 60 : 180,
isTablet ? 240 : 380
]);
const panesRef = useRef<AllotmentHandle>(null);
const [isNarrow, setIsNarrow] = useState(paneSizes[0] <= 55);
return (
<>
<Flex
variant="rowFill"
sx={{
overflow: "hidden"
}}
>
<Allotment
ref={panesRef}
proportionalLayout={false}
onDragEnd={(sizes) => {
setPaneSizes(sizes);
setIsNarrow(sizes[0] <= 55);
}}
>
<Allotment.Pane
className="pane nav-pane"
minSize={50}
preferredSize={isTablet ? 50 : paneSizes[0]}
visible={!isFocusMode}
priority={LayoutPriority.Low}
>
<NavigationMenu
toggleNavigationContainer={(state) => {
setShow(state || !show);
}}
isTablet={isNarrow}
/>
</Allotment.Pane>
<Allotment.Pane
className="pane middle-pane"
minSize={2}
preferredSize={paneSizes[1]}
visible={show}
priority={LayoutPriority.Normal}
>
<ScopedThemeProvider
className="listMenu"
scope="list"
sx={{
display: "flex",
flexDirection: "column",
flex: 1,
bg: "background"
}}
>
{isAppLoaded && <CachedRouter />}
</ScopedThemeProvider>
</Allotment.Pane>
<Allotment.Pane
className="pane editor-pane"
priority={LayoutPriority.High}
>
<Flex
sx={{
display: "flex",
overflow: "hidden",
flex: 1,
flexDirection: "column",
bg: "background"
}}
>
{isAppLoaded && (
<SuspenseLoader
fallback={<EditorLoader />}
component={HashRouter}
condition={isAppLoaded}
/>
)}
</Flex>
</Allotment.Pane>
<Allotment.Pane
className="pane theme-builder-pane"
minSize={50}
maxSize={250}
>
<ThemeBuilder />
</Allotment.Pane>
</Allotment>
</Flex>
<StatusBar />
</>
);
}
function MobileAppContents({ isAppLoaded }: { isAppLoaded: boolean }) {
return (
<FlexScrollContainer
id="slider"
suppressScrollX
style={{
display: "flex",
flexDirection: "row",
overflowY: "hidden",
scrollSnapType: "x mandatory",
scrollBehavior: "smooth",
WebkitOverflowScrolling: "touch",
scrollSnapStop: "always",
overscrollBehavior: "contain",
overflowX: "auto",
flex: 1
}}
>
<Flex
sx={{
scrollSnapAlign: "start",
scrollSnapStop: "always",
width: [300, 60],
flexShrink: 0
}}
>
<NavigationMenu toggleNavigationContainer={() => {}} isTablet={false} />
</Flex>
<Flex
className="listMenu"
variant="columnFill"
sx={{
position: "relative",
scrollSnapAlign: "start",
scrollSnapStop: "always",
flexShrink: 0,
width: "100vw"
}}
>
<SuspenseLoader
condition={isAppLoaded}
component={CachedRouter}
fallback={<ViewLoader />}
/>
<Box
id="overlay"
sx={{
position: "absolute",
width: "100%",
height: "100%",
top: 0,
left: 0,
zIndex: 999,
opacity: 0,
visibility: "visible",
pointerEvents: "none"
}}
bg="black"
/>
</Flex>
<Flex
sx={{
scrollSnapAlign: "start",
scrollSnapStop: "always",
flexDirection: "column",
flexShrink: 0,
width: "100vw"
}}
>
<SuspenseLoader
fallback={<EditorLoader />}
component={HashRouter}
condition={isAppLoaded}
/>
</Flex>
</FlexScrollContainer>
);
}

View File

@@ -0,0 +1,599 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { debounce } from "@notesnook/common";
import {
COLORS,
ThemeAuthor,
ThemeDefinition,
THEME_SCOPES,
Variants,
validateTheme
} from "@notesnook/theme";
import { Button, Flex, Input, Text } from "@theme-ui/components";
import FileSaver from "file-saver";
import { useCallback, useRef, useState } from "react";
import { useStore } from "@notesnook/web/src/stores/theme-store";
import { showToast } from "@notesnook/web/src/utils/toast";
import Accordion from "@notesnook/web/src/components/accordion";
import { showFilePicker } from "@notesnook/web/src/utils/file-picker";
import Field from "@notesnook/web/src/components/field";
import { Close } from "@notesnook/web/src/components/icons";
const JSON_SCHEMA_URL =
"https://raw.githubusercontent.com/streetwriters/notesnook-themes/main/schemas/v1.schema.json";
const ThemeInfoTemplate: Omit<
ThemeDefinition,
"authors" | "compatibilityVersion" | "colorScheme" | "codeBlockCSS" | "scopes"
> = {
name: "",
id: "",
version: 0,
license: "",
homepage: "",
description: ""
};
function toTitleCase(value: string) {
return (
value.slice(0, 1).toUpperCase() +
value.slice(1).replace(/([A-Z]+)*([A-Z][a-z])/g, "$1 $2")
);
}
const flatten = (object: { [name: string]: any }) => {
const flattenedObject: { [name: string]: any } = {};
for (const innerObj in object) {
if (typeof object[innerObj] === "object") {
if (typeof object[innerObj] === "function") continue;
const newObject = flatten(object[innerObj]);
for (const key in newObject) {
flattenedObject[innerObj + "." + key] = newObject[key];
}
} else {
if (typeof object[innerObj] === "function") continue;
flattenedObject[innerObj] = object[innerObj];
}
}
return flattenedObject;
};
function unflatten(data: any) {
const result = {};
for (const i in data) {
const keys = i.split(".");
keys.reduce(function (r: any, e, j) {
return (
r[e] ||
(r[e] = isNaN(Number(keys[j + 1]))
? keys.length - 1 == j
? data[i]
: {}
: [])
);
}, result);
}
return result;
}
export default function ThemeBuilder() {
const currentTheme = useStore((state: any) =>
state.colorScheme === "dark" ? state.darkTheme : state.lightTheme
);
const setTheme = useStore((state: any) => state.setTheme);
const [loading, setLoading] = useState(false);
const currentThemeFlattened = flatten(currentTheme);
const [authors, setAuthors] = useState(
currentTheme.authors || [
{
name: ""
}
]
);
const formRef = useRef(null);
const onChange: React.FormEventHandler<HTMLDivElement> = useCallback(
debounce(() => {
if (!formRef.current) return;
const body = new FormData(formRef.current);
const flattenedThemeRaw = {
...Object.fromEntries(body.entries()),
...flatten({ authors: [...authors] })
};
const flattenedTheme: { [name: string]: any } = {};
for (const key in flattenedThemeRaw) {
if (flattenedThemeRaw[key] === "" || !flattenedThemeRaw[key]) continue;
if (key === "compatibilityVersion" || key === "version") {
flattenedTheme[key] = parseFloat(flattenedThemeRaw[key]);
} else {
flattenedTheme[key] = flattenedThemeRaw[key];
}
}
const theme = unflatten(flattenedTheme);
const result = validateTheme(theme as ThemeDefinition);
if (result.error) {
showToast("error", result.error);
return;
}
setTheme({ ...theme } as ThemeDefinition);
}, 3000),
[]
);
const loadThemeFile = async () => {
const file = await showFilePicker({
acceptedFileTypes: ".nnbackup,application/json,.json"
});
if (!file) return;
const reader = new FileReader();
const theme = (await new Promise((resolve) => {
reader.addEventListener("load", (event) => {
const text = event.target?.result;
try {
resolve(JSON.parse(text as string));
} catch (e) {
alert(
"Error: Could not read the backup file provided. Either it's corrupted or invalid."
);
resolve(undefined);
}
});
reader.readAsText(file);
})) as ThemeDefinition | undefined;
if (
!theme ||
!theme.scopes ||
!theme.compatibilityVersion ||
!theme.id ||
!theme.version
)
return;
setLoading(true);
setTheme(theme);
setLoading(false);
};
const exportTheme = () => {
const json = JSON.stringify({
...currentTheme,
$schema: JSON_SCHEMA_URL
});
FileSaver.saveAs(
new Blob([json], {
type: "text/plain"
}),
`${currentTheme.id}.json`
);
};
const onChangeColor = (
target: HTMLInputElement,
sibling: HTMLInputElement
) => {
const value = target.value;
if ((sibling as HTMLInputElement).value !== value) {
(sibling as HTMLInputElement).value = target.value;
}
};
return loading ? null : (
<Flex
sx={{
display: "flex",
overflow: "hidden",
flex: 1,
flexDirection: "column",
height: "100%",
overflowY: "scroll",
padding: "10px 10px",
rowGap: "10px"
}}
>
<Flex
sx={{
justifyContent: "space-between",
alignItems: "center"
}}
>
<Text
sx={{
fontSize: "12px"
}}
variant="heading"
>
Theme Builder 1.0
</Text>
</Flex>
<Button sx={{ py: "7px" }} variant="secondary" onClick={exportTheme}>
<Text
sx={{
fontSize: "12px"
}}
>
Export theme
</Text>
</Button>
<Button sx={{ py: "7px" }} variant="secondary" onClick={loadThemeFile}>
<Text
sx={{
fontSize: "12px"
}}
>
Load theme file
</Text>
</Button>
<Flex
as="form"
id="theme-form"
ref={formRef}
onChange={onChange}
onSubmit={(event) => {
event?.preventDefault();
}}
sx={{
flexDirection: "column",
rowGap: "0.5rem"
}}
>
{Object.keys(ThemeInfoTemplate).map((key) => {
return (
<Field
key={key}
label={toTitleCase(key)}
name={key}
defaultValue={currentThemeFlattened[key]}
styles={{
label: {
fontSize: "12px",
fontWeight: "normal",
color: "paragraph-secondary"
},
input: {
marginLeft: "0px",
marginRight: "0px",
height: "30px",
fontSize: "12px"
}
}}
/>
);
})}
<SelectItem
label="Color Scheme"
options={[
{
title: "Light",
value: "light"
},
{
title: "Dark",
value: "dark"
}
]}
defaultValue={currentThemeFlattened["colorScheme"]}
name="colorScheme"
key="colorScheme"
/>
<SelectItem
label="Compatibility version"
name="compatibilityVersion"
options={[
{
title: "1.0",
value: 1.0
}
]}
defaultValue={currentThemeFlattened["compatibilityVersion"]}
key="compatibilityVersion"
/>
{authors.map((author: any, index: number) => (
<Flex
key={author.name}
sx={{
flexDirection: "column"
}}
>
<Flex
style={{
justifyContent: "space-between",
alignItems: "center"
}}
>
<Text
sx={{
fontSize: "12px",
color: "paragraph-secondary",
flexShrink: 0,
flex: 0.5,
marginRight: "20px",
mb: 1
}}
>
Author {index + 1}
</Text>
{authors.length > 1 ? (
<Button
sx={{
height: 25
}}
onClick={() => {
if (authors.length === 1) {
console.log("Theme must have at least one author");
return;
}
setAuthors((current: any) => {
const authors = [...current];
authors.splice(index, 1);
return authors;
});
}}
>
<Close
sx={{
width: 15,
height: 15
}}
/>
</Button>
) : null}
</Flex>
{["name", "email", "url"].map((key) => (
<>
<Field
key={key}
label={toTitleCase(key)}
name={`authors.${index}.${key}`}
required={key === "name"}
defaultValue={author[key as keyof ThemeAuthor]}
styles={{
container: {
ml: 1
},
label: {
fontSize: "12px",
fontWeight: "normal",
color: "paragraph-secondary"
},
input: {
marginLeft: "0px",
marginRight: "0px",
height: "30px",
fontSize: "12px"
}
}}
/>
</>
))}
</Flex>
))}
<Button
onClick={() => {
setAuthors((current: any) => {
const authors = [...current];
authors.push({
name: ""
});
return authors;
});
}}
variant="secondary"
type="submit"
>
<Text color="accent">Add author</Text>
</Button>
{THEME_SCOPES.map((scopeName) => (
<>
<Accordion
isClosed={false}
buttonSx={{
backgroundColor: "transparent",
borderBottom: "1px solid var(--border)",
borderRadius: 0,
p: 0,
py: 1
}}
titleSx={{
fontSize: "12px",
color: "paragraph-secondary"
}}
title={toTitleCase(scopeName)}
>
{Variants.map((variantName) => (
<>
<Accordion
isClosed={true}
buttonSx={{
backgroundColor: "transparent",
p: 0,
py: 1
}}
titleSx={{
fontSize: "12px",
color: "paragraph-secondary",
fontWeight: "normal",
ml: 1
}}
title={toTitleCase(variantName)}
>
{COLORS.map((colorName) => (
<Flex
key={colorName}
sx={{
alignItems: "center",
width: "100%",
justifyContent: "flex-start",
ml: 3
}}
>
<Text
sx={{
fontSize: "12px",
color: "paragraph-secondary",
flexShrink: 0,
flex: 0.5,
marginRight: "20px"
}}
>
{colorName}
</Text>
<Input
sx={{
fontSize: "12px",
height: "25px",
borderRadius: 0,
flex: 0.4,
borderBottom: "1px solid var(--border)",
outline: "none",
":hover": {
borderWidth: "0px",
outline: "none"
}
}}
title={
/hover|shade|backdrop|textSelection/g.test(
colorName
)
? `Hex RGB & ARGB values both are supported. (e.g. #dbdbdb99)`
: `Only Hex RGB values are supported. No Alpha. (e.g. #f33ff3)`
}
required={scopeName === "base"}
name={`scopes.${scopeName}.${variantName}.${colorName}`}
defaultValue={
currentThemeFlattened[
`scopes.${scopeName}.${variantName}.${colorName}`
]
}
onChange={(event) => {
onChangeColor(
event.target,
event.target
.nextElementSibling as HTMLInputElement
);
}}
/>
<Input
type="color"
onChange={(event) => {
onChangeColor(
event.target,
event.target
.previousElementSibling as HTMLInputElement
);
}}
title={
/hover|shade|backdrop|textSelection/g.test(
colorName
)
? `Only Hex RGB values are supported. No Alpha. (e.g. #f33ff3)`
: `Hex RGB & ARGB values both are supported. (e.g. #dbdbdb99)`
}
defaultValue={
currentThemeFlattened[
`scopes.${scopeName}.${variantName}.${colorName}`
]
}
sx={{
borderRadius: 0,
borderBottom: "1px solid var(--border)",
outline: "none",
width: "20px",
height: "20px",
padding: "0px"
}}
/>
</Flex>
))}
</Accordion>
</>
))}
</Accordion>
</>
))}
</Flex>
</Flex>
);
}
function SelectItem(props: {
options: { title: string; value: any }[];
defaultValue: any;
onChange?: (value: string) => void;
label: string;
name: string;
}) {
return (
<Flex
sx={{
flexDirection: "column"
}}
>
<Text
sx={{
fontSize: "12px",
fontWeight: "normal",
color: "paragraph-secondary"
}}
mb={1}
>
{props.label}
</Text>
<select
style={{
backgroundColor: "var(--background)",
outline: "none",
border: "1px solid var(--border-secondary)",
borderRadius: "5px",
color: "var(--paragraph)",
height: "33px",
fontSize: "12px"
}}
name={props.name}
defaultValue={props.defaultValue}
onChange={(e) => {
const value = (e.target as HTMLSelectElement).value;
props.onChange?.(value);
}}
>
{props.options.map((option) => (
<option key={option.value} value={option.value}>
{option.title}
</option>
))}
</select>
</Flex>
);
}

37
apps/theme-builder/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,37 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable no-var */
import "vite/client";
import "vite-plugin-svgr/client";
declare global {
var PUBLIC_URL: string;
var APP_VERSION: string;
var GIT_HASH: string;
var IS_DESKTOP_APP: boolean;
var IS_TESTING: boolean;
var PLATFORM: "web" | "desktop";
var IS_BETA: boolean;
interface Window {
os?: () => NodeJS.Platform | "mas";
NativeNNCrypto?: new () => import("@notesnook/crypto").NNCrypto;
}
}

View File

@@ -0,0 +1,265 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAHUUExURUxpcf////////////////////////////////////////////////////////////////////////////////////39/f39/f////////////////////////////////z8/O3s7P7+/vTz8wAAAPz7++/v7/Py8u/u7vDv7+7t7dfX1/79/ezr66Kiovv6+vf29vX09OTj4w0NDfv7++Ti4vr6+vT09Ovq6vn5+f39/fb19dnX1/b29tvb29/f3xMTE/j4+Pn4+O7s7MnJyerq6uno6M/Pz8jIyN3d3dnZ2d7e3kZGRvj39/38/Pf39+zs7PDw8OPi4ubl5cvLy+rp6czMzPLy8vPz887Ozujo6MrKyuDf3+fn59PT0+bm5uXl5c3NzfX19evr6wsLC+/t7ePj4/Ty8u3r6/Lx8eXk5OPh4dDQ0Nza2tjX19vZ2fHw8Nva2tLR0djW1tbW1tra2tLS0uHh4evp6eLi4tXV1djY2L+/vysrK7q6utHR0aGhoa6urrm5uQICAigoKCEhIZCQkFxcXBgYGFRUVBISEm1tbQ4ODklJSbu7uyAgID09PZqamkVFRXh4eLGxsTg4OOTk5FFRUQEBAQoKCnx8fBcXF7/+rP8AAAAfdFJOUwBOqO2h6KT9Mtyr5+w3Str+6TTZOjXb31M24O8BOd0PapSsAAABxElEQVQ4y2NgYOBgYWWXxwLYBdmEGICAjx9Z1NJJVQfB42QE6hdG0RaoaaCqhKSCl4EF1VxNVAXyTAzcaArUUBXwMLBjKDBBViDAII+uwBZFgTy6AjU1awIKNKz1aa0g1VBfB58CDS08CuIb40AKzLAqsJKXN2tXacChwKS+rTtB3tJHRU9ewxmbFZ6tKiqTrSAKdIO1LDEUaPeoqMzsUwAp0NIt8Uj0ijJXQFVgnDJVZcK0XrACXcXY0sjoHM8AfyUkBbOn9KvMmgRSYFEW4hUVqJagHeaShqTAwX7ijJQusAkW5VXRHu6eruFqaigKkqarqAAVKFbYxWiYq1XGedijuMHBWz6xo7m2Rl7RwtvdJaw6NjzCFl2BVVNdshJQgbqBsqtdpIu7nasjXIFXS2cyRL2CW6i6JTCgFCLitUPs4QrM7cDmKZi7FRYVm6o5QeLbDGICLNEqJAVk59rk2djY5Me4FThawQOKFSzrrxmaZWpqBAamQUGmwRaakNTNzMAm7+ekrKmlqOjsrIgAGcAA1ci0NVPiYhAV8zVURgbp6nBg7isuwsDAKKfkp4AEVOHASklKApS9JZmY5bECaS5ZGQYA3vqpy6NoYh0AAABXelRYdFJhdyBwcm9maWxlIHR5cGUgaXB0YwAAeJzj8gwIcVYoKMpPy8xJ5VIAAyMLLmMLEyMTS5MUAxMgRIA0w2QDI7NUIMvY1MjEzMQcxAfLgEigSi4A6hcRdPJCNZUAAAAASUVORK5CYII="
/>
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<!-- <link rel="manifest" href="/site.webmanifest" /> -->
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#01c352" />
<meta name="msapplication-TileColor" content="#01c352" />
<meta name="theme-color" content="#01c352" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<!-- <link rel="manifest" href="/manifest.json" /> -->
<!--
Notice the use of in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<meta name="description" content="Your private note taking space" />
<meta property="og:title" content="Notesnook" />
<meta
property="og:description"
content="A fully open source & end-to-end encrypted note taking alternative to Evernote."
/>
<meta property="og:image" content="https://app.notesnook.com/banner.jpg" />
<meta
property="og:image:alt"
content="A fully open source & end-to-end encrypted note taking alternative to Evernote."
/>
<meta property="og:url" content="https://app.notesnook.com/" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Notesnook by Streetwriters LLC" />
<meta name="twitter:card" content="summary_large_image" />
<title>Notesnook</title>
<script nonce="7WIq8hRwApoXhctoGZZthMLYQLRNiprTwcPi6Azdf">
const colorScheme = JSON.parse(
window.localStorage.getItem("colorScheme") || '"light"'
);
const root = document.querySelector("html");
if (root) root.setAttribute("data-theme", colorScheme);
</script>
<script type="module" src="/index.tsx"></script>
<style>
html {
overscroll-behavior: none;
}
html[data-theme="dark"] {
--bg: #0f0f0f;
--fg: #fff;
--three-bars-bg: #494949;
--gradient-stop-color: #111111;
--n-letter-color: #e1e1e1;
}
html[data-theme="light"] {
--bg: #fff;
--three-bars-bg: #bebebe;
--gradient-stop-color: #fff9f9;
--n-letter-color: #000;
}
html[data-theme="light"] .react-loading-skeleton {
--base-color: var(--background-secondary);
--highlight-color: #var(--background-secondary);
}
html[data-theme="dark"] .react-loading-skeleton {
--base-color: var(--background-secondary);
--highlight-color: var(--background-secondary);
}
#splash svg {
transform: scale(1);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(0.95);
}
70% {
transform: scale(1);
}
100% {
transform: scale(0.95);
}
}
html,
body {
font-size: 16px;
}
@media only screen and (max-width: 480px) {
html,
body {
font-size: 18px;
background-color: var(--background);
}
}
:root,
body,
#root {
margin: 0 !important;
height: 100%;
overflow: hidden;
}
@keyframes fadeUp {
0% {
transform: translateY(500px);
opacity: 0;
}
80% {
transform: translateY(0px);
opacity: 0.7;
}
100% {
opacity: 1;
}
}
/* svg {
width: 1.5rem;
} */
.ReactModal__Overlay {
opacity: 0;
transition: opacity 200ms ease-in-out;
}
.ReactModal__Overlay--after-open {
opacity: 1;
}
.ReactModal__Overlay--before-close {
opacity: 0;
}
.slide {
background-color: transparent !important;
}
.carousel.carousel-slider {
display: flex;
flex-direction: row;
}
</style>
</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>
-->
<div id="root"></div>
<div
id="splash"
style="
background-color: var(--bg);
height: 100%;
width: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
"
>
<svg viewBox="0 0 339 339" style="width: 150px">
<defs />
<defs>
<linearGradient
xlink:href="#a"
id="b"
x1="188.61227"
x2="193.54405"
y1="165.2058"
y2="216.81519"
gradientTransform="rotate(5 4448 -4204) scale(2.93671)"
gradientUnits="userSpaceOnUse"
/>
<linearGradient id="a">
<stop offset="0" />
<stop
offset="1"
stop-color="var(--gradient-stop-color)"
stop-opacity="0"
/>
</linearGradient>
<linearGradient
id="c"
x1="167.8"
x2="270.6"
y1="76.9"
y2="64.2"
gradientTransform="rotate(5 465 -2050) scale(1.50082)"
gradientUnits="userSpaceOnUse"
xlink:href="#a"
/>
</defs>
<g transform="translate(0 42)">
<path fill="url(#b)" d="M160 205l154 42-141 44-155-42z" />
<path fill="url(#c)" d="M160-35v240l154 42 1-253z" />
<path
fill="none"
stroke-width="1.2"
d="M160 205V-35m0 240L18 249m142-44l154 41"
/>
<path
fill="var(--n-letter-color)"
d="M84 109l35 54V98l21-7v91l-27 9-35-54v65l-21 6v-91z"
/>
<rect
width="86.1"
height="12.6"
x="185"
y="97"
fill="var(--three-bars-bg)"
ry="2.3"
transform="skewY(15) scale(.9669 1)"
/>
<path
fill="var(--three-bars-bg)"
d="M181 169l99 26 2 3v8c0 1-1 2-2 1l-99-26-2-3v-7c0-2 1-2 2-2zm0-47l99 27 2 2v8l-2 2-99-27c-1 0-2-1-2-3v-7l2-2z"
/>
</g>
</svg>
</div>
<div id="dialogContainer"></div>
<div id="floatingViewContainer"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@@ -0,0 +1,209 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import "./polyfills";
import "@notesnook/core/types";
import {
AppEventManager,
AppEvents
} from "@notesnook/web/src/common/app-events";
import { render } from "react-dom";
import {
getCurrentHash,
getCurrentPath,
makeURL
} from "@notesnook/web/src/navigation";
import Config from "@notesnook/web/src/utils/config";
import { initalizeLogger, logger } from "@notesnook/web/src/utils/logger";
import { AuthProps } from "@notesnook/web/src/views/auth";
import { loadDatabase } from "@notesnook/web/src/hooks/use-database";
type Route<TProps = null> = {
component: () => Promise<{
default: TProps extends null
? () => JSX.Element
: (props: TProps) => JSX.Element;
}>;
props: TProps | null;
};
type RouteWithPath<T = null> = {
route: Route<T>;
path: Routes;
};
type Routes = keyof typeof routes;
// | "/account/recovery"
// | "/account/verified"
// | "/signup"
// | "/login"
// | "/sessionexpired"
// | "/recover"
// | "/mfa/code"
// | "/mfa/select"
// | "default";
const routes = {
"/account/recovery": {
component: () => import("@notesnook/web/src/views/recovery"),
props: { route: "methods" }
},
"/account/verified": {
component: () => import("@notesnook/web/src/views/email-confirmed"),
props: {}
},
"/signup": {
component: () => import("@notesnook/web/src/views/auth"),
props: { route: "signup" }
},
"/sessionexpired": {
component: () => import("@notesnook/web/src/views/auth"),
props: { route: "sessionExpiry" }
},
"/login": {
component: () => import("@notesnook/web/src/views/auth"),
props: { route: "login:email" }
},
"/login/password": {
component: () => import("@notesnook/web/src/views/auth"),
props: { route: "login:email" }
},
"/recover": {
component: () => import("@notesnook/web/src/views/auth"),
props: { route: "recover" }
},
"/login/mfa/code": {
component: () => import("@notesnook/web/src/views/auth"),
props: { route: "login:email" }
},
"/login/mfa/select": {
component: () => import("@notesnook/web/src/views/auth"),
props: { route: "login:email" }
},
default: { component: () => import("./app"), props: null }
} as const;
const sessionExpiryExceptions: Routes[] = [
"/recover",
"/account/recovery",
"/sessionexpired",
"/login/mfa/code",
"/login/mfa/select",
"/login/password"
];
const serviceWorkerWhitelist: Routes[] = ["default"];
function getRoute(): RouteWithPath<AuthProps> | RouteWithPath {
const path = getCurrentPath() as Routes;
logger.info(`Getting route for path: ${path}`);
const signup = redirectToRegistration(path);
const sessionExpired = isSessionExpired(path);
const fallback = fallbackRoute();
const route = (
routes[path] ? { route: routes[path], path } : null
) as RouteWithPath<AuthProps> | null;
return signup || sessionExpired || route || fallback;
}
function fallbackRoute(): RouteWithPath {
return { route: routes.default, path: "default" };
}
function redirectToRegistration(path: Routes): RouteWithPath<AuthProps> | null {
if (!IS_TESTING && !shouldSkipInitiation() && !routes[path]) {
window.history.replaceState({}, "", makeURL("/signup", getCurrentHash()));
return { route: routes["/signup"], path: "/signup" };
}
return null;
}
function isSessionExpired(path: Routes): RouteWithPath<AuthProps> | null {
const isSessionExpired = Config.get("sessionExpired", false);
if (isSessionExpired && !sessionExpiryExceptions.includes(path)) {
logger.info(`User session has expired. Routing to /sessionexpired`);
window.history.replaceState(
{},
"",
makeURL("/sessionexpired", getCurrentHash())
);
return { route: routes["/sessionexpired"], path: "/sessionexpired" };
}
return null;
}
renderApp();
async function renderApp() {
await initalizeLogger();
const {
path,
route: { component, props }
} = getRoute();
if (serviceWorkerWhitelist.includes(path)) await initializeServiceWorker();
if (IS_DESKTOP_APP) await loadDatabase("db");
logger.measure("app render");
const { default: Component } = await component();
render(
<Component route={props?.route || "login:email"} />,
document.getElementById("root"),
() => {
logger.measure("app render");
document.getElementById("splash")?.remove();
}
);
}
async function initializeServiceWorker() {
if (!IS_DESKTOP_APP) {
logger.info("Initializing service worker...");
const serviceWorker = await import("./service-worker-registration");
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.register({
onUpdate: async (registration: ServiceWorkerRegistration) => {
if (!registration.waiting) return;
const { getServiceWorkerVersion } = await import(
"@notesnook/web/src/utils/version"
);
const { formatted } = await getServiceWorkerVersion(
registration.waiting
);
AppEventManager.publish(AppEvents.updateDownloadCompleted, {
version: formatted
});
}
});
// window.addEventListener("beforeinstallprompt", () => showInstallNotice());
}
}
function shouldSkipInitiation() {
return localStorage.getItem("skipInitiation") || false;
}

View File

@@ -0,0 +1,21 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Buffer } from "buffer";
window.Buffer = Buffer;

View File

@@ -0,0 +1,20 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,175 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://cra.link/PWA
type ServiceWorkerRegistrationConfig = {
onUpdate?: (registration: ServiceWorkerRegistration) => void;
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onError?: (error: Error) => void;
};
const isLocalhost = Boolean(
window.location.hostname === "localhost" ||
// [::1] is the IPv6 localhost address.
window.location.hostname === "[::1]" ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config: ServiceWorkerRegistrationConfig) {
if (import.meta.env.PROD && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener("load", () => {
const swUrl = `${PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://cra.link/PWA"
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(
swUrl: string,
config: ServiceWorkerRegistrationConfig
) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
"New content is available and will be used when all " +
"tabs for this page are closed. See https://cra.link/PWA."
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log("Content is cached for offline use.");
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error("Error during service worker registration:", error);
});
}
function checkValidServiceWorker(
swUrl: string,
config: ServiceWorkerRegistrationConfig
) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { "Service-Worker": "script" }
})
.then((response) => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get("content-type");
if (
response.status === 404 ||
(contentType != null && contentType.indexOf("javascript") === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch((e) => {
console.log(
"No internet connection found. App is running in offline mode."
);
if (config && config.onError) {
config.onError(e);
}
});
}
export function unregister() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}

View File

@@ -0,0 +1,106 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable no-var */
/// <reference lib="webworker" />
import { clientsClaim } from "workbox-core";
import { ExpirationPlugin } from "workbox-expiration";
import { precacheAndRoute, createHandlerBoundToURL } from "workbox-precaching";
import { registerRoute } from "workbox-routing";
import { StaleWhileRevalidate } from "workbox-strategies";
declare var self: ServiceWorkerGlobalScope & typeof globalThis;
clientsClaim();
const precacheRoutes = self.__WB_MANIFEST;
const filters = [/KaTeX/i, /hack/i, /code-lang-/i];
precacheAndRoute(
precacheRoutes.filter((route) => {
return filters.every(
(filter) => !filter.test(typeof route === "string" ? route : route.url)
);
})
);
// Set up App Shell-style routing, so that all navigation requests
// are fulfilled with your index.html shell. Learn more at
// https://developers.google.com/web/fundamentals/architecture/app-shell
const fileExtensionRegexp = new RegExp("/[^/?]+\\.[^/]+$");
registerRoute(
// Return false to exempt requests from being fulfilled by index.html.
({ request, url }) => {
// If this isn't a navigation, skip.
if (request.mode !== "navigate") {
return false;
} // If this is a URL that starts with /_, skip.
if (url.pathname.startsWith("/_")) {
return false;
} // If this looks like a URL for a resource, because it contains // a file extension, skip.
if (url.pathname.match(fileExtensionRegexp)) {
return false;
} // Return true to signal that we want to use the handler.
return true;
},
createHandlerBoundToURL(PUBLIC_URL + "/index.html")
);
// An example runtime caching route for requests that aren't handled by the
// precache, in this case same-origin .png requests like those from in public/
registerRoute(
// Add in any other file extensions or routing criteria as needed.
({ url }) =>
url.origin === self.location.origin && url.pathname.endsWith(".png"), // Customize this strategy as needed, e.g., by changing to CacheFirst.
new StaleWhileRevalidate({
cacheName: "images",
plugins: [
// Ensure that once this runtime cache reaches a maximum size the
// least-recently used images are removed.
new ExpirationPlugin({ maxEntries: 50 })
]
})
);
// This allows the web app to trigger skipWaiting via
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
self.addEventListener("message", (event) => {
const { data } = event;
if (!data) return;
switch (data.type) {
case "SKIP_WAITING":
self.skipWaiting();
break;
case "GET_VERSION":
{
if (!event.source) return;
event.source.postMessage({
type: data.type,
version: APP_VERSION,
hash: GIT_HASH
});
}
break;
default:
break;
}
});

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig",
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"jsx": "react-jsx",
"maxNodeModuleJsDepth": 10,
"noEmit": true,
"downlevelIteration": true
},
"include": ["src", "global.d.ts"]
}

View File

@@ -0,0 +1,172 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { PluginOption, defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import svgrPlugin from "vite-plugin-svgr";
import envCompatible from "vite-plugin-env-compatible";
import { VitePWA } from "vite-plugin-pwa";
import autoprefixer from "autoprefixer";
import { WEB_MANIFEST } from "./web-manifest";
import { execSync } from "child_process";
import { version } from "./package.json";
import { visualizer } from "rollup-plugin-visualizer";
import { OutputPlugin } from "rollup";
const gitHash = (() => {
try {
return execSync("git rev-parse --short HEAD").toString().trim();
} catch (e) {
return process.env.GIT_HASH || "gitless";
}
})();
const appVersion = version.replaceAll(".", "");
const isTesting =
process.env.TEST === "true" || process.env.NODE_ENV === "development";
const isDesktop = process.env.PLATFORM === "desktop";
const isAnalyzing = process.env.ANALYZING === "true";
process.env.NN_BUILD_TIMESTAMP = isTesting ? "0" : `${Date.now()}`;
export default defineConfig({
envPrefix: "NN_",
root: "src/",
publicDir: "../node_modules/@notesnook/web/public",
build: {
target: isDesktop ? "esnext" : "modules",
outDir: "../build",
minify: "esbuild",
cssMinify: true,
emptyOutDir: true,
rollupOptions: {
output: {
plugins: [emitEditorStyles()],
assetFileNames: "assets/[name]-[hash:12][extname]",
chunkFileNames: "assets/[name]-[hash:12].js"
}
}
},
define: {
GIT_HASH: `"${gitHash}"`,
APP_VERSION: `"${appVersion}"`,
PUBLIC_URL: `"${process.env.PUBLIC_URL || ""}"`,
IS_DESKTOP_APP: isDesktop,
PLATFORM: `"${process.env.PLATFORM}"`,
IS_TESTING: process.env.TEST === "true",
IS_BETA: process.env.BETA === "true"
},
logLevel: process.env.NODE_ENV === "production" ? "warn" : "info",
resolve: {
dedupe: [
"react",
"react-dom",
"@mdi/js",
"@mdi/react",
"@emotion/react",
"katex"
],
alias: [
{
find: /desktop-bridge/gm,
replacement: isDesktop
? "desktop-bridge/index.desktop"
: "desktop-bridge/index"
}
]
},
server: {
port: 3000
},
worker: {
format: "es",
rollupOptions: {
output: {
inlineDynamicImports: true
}
}
},
css: {
postcss: {
plugins: [autoprefixer()]
}
},
plugins: [
...(isAnalyzing
? [
visualizer({
gzipSize: true,
brotliSize: true,
open: true
}) as PluginOption
]
: []),
...(isDesktop && process.env.NODE_ENV === "production"
? []
: [
VitePWA({
strategies: "injectManifest",
minify: true,
manifest: WEB_MANIFEST,
injectRegister: null,
srcDir: "",
filename: "service-worker.ts"
})
]),
react({
plugins: isTesting
? undefined
: [["swc-plugin-react-remove-properties", {}]]
}),
envCompatible({
prefix: "NN_",
mountedPath: "process.env"
}),
svgrPlugin({
svgrOptions: {
icon: true
// ...svgr options (https://react-svgr.com/docs/options/)
}
})
]
});
function emitEditorStyles(): OutputPlugin {
return {
name: "rollup-plugin-emit-editor-styles",
generateBundle(options, bundle) {
for (const file in bundle) {
const chunk = bundle[file];
if (
chunk.type === "asset" &&
chunk.fileName.endsWith(".css") &&
typeof chunk.source === "string" &&
(chunk.source.includes("KaTeX_Fraktur-Bold-") ||
chunk.source.includes("Hack typeface"))
) {
this.emitFile({
type: "asset",
fileName: "assets/editor-styles.css",
name: "editor-styles.css",
source: chunk.source
});
}
}
}
};
}

View File

@@ -0,0 +1,135 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ManifestOptions } from "vite-plugin-pwa";
export const WEB_MANIFEST: Partial<ManifestOptions> = {
name: "Notesnook",
description:
"A fully open source & end-to-end encrypted note taking alternative to Evernote.",
short_name: "Notesnook",
shortcuts: [
{
name: "New note",
url: "/#/notes/create",
description: "Create a new note",
icons: [
{
src: "/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png"
}
]
},
{
name: "New notebook",
url: "/#/notebooks/create",
description: "Create a new notebook",
icons: [
{
src: "/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png"
}
]
}
],
icons: [
{
src: "favicon-16x16.png",
sizes: "16x16",
type: "image/png"
},
{
src: "favicon-32x32.png",
sizes: "32x32",
type: "image/png"
},
{
src: "favicon-72x72.png",
sizes: "72x72",
type: "image/png"
},
{
src: "/android-chrome-192x192.png",
sizes: "192x192",
type: "image/png"
},
{
src: "/android-chrome-512x512.png",
sizes: "512x512",
type: "image/png"
}
],
screenshots: [
{
src: "/screenshots/screenshot-1.jpg",
sizes: "1080x1920",
type: "image/jpeg"
},
{
src: "/screenshots/screenshot-2.jpg",
sizes: "1080x1920",
type: "image/jpeg"
},
{
src: "/screenshots/screenshot-3.jpg",
sizes: "1080x1920",
type: "image/jpeg"
},
{
src: "/screenshots/screenshot-4.jpg",
sizes: "1080x1920",
type: "image/jpeg"
},
{
src: "/screenshots/screenshot-5.jpg",
sizes: "1080x1920",
type: "image/jpeg"
},
{
src: "/screenshots/screenshot-6.jpg",
sizes: "1080x1920",
type: "image/jpeg"
},
{
src: "/screenshots/screenshot-7.jpg",
sizes: "1080x1920",
type: "image/jpeg"
}
],
related_applications: [
{
platform: "play",
url: "https://play.google.com/store/apps/details?id=com.streetwriters.notesnook",
id: "com.streetwriters.notesnook"
},
{
platform: "itunes",
url: "https://apps.apple.com/us/app/notesnook-private-notes-app/id1544027013"
}
],
prefer_related_applications: true,
orientation: "any",
start_url: ".",
theme_color: "#01c352",
background_color: "#ffffff",
display: "standalone",
categories: ["productivity", "lifestyle", "education", "books"]
};

View File

@@ -27,6 +27,8 @@ export type AccordionProps = {
isClosed: boolean;
color?: SchemeColors;
testId?: string;
buttonSx?: FlexProps["sx"];
titleSx?: FlexProps["sx"];
};
export default function Accordion(
@@ -48,14 +50,15 @@ export default function Accordion(
cursor: "pointer",
bg: "var(--background-secondary)",
p: 1,
borderRadius: "default"
borderRadius: "default",
...props.buttonSx
}}
onClick={() => {
setIsContentHidden((state) => !state);
}}
data-test-id={testId}
>
<Text variant="subtitle" sx={{ color }}>
<Text variant="subtitle" sx={{ color, ...props.titleSx }}>
{title}
</Text>
{isContentHidden ? (

View File

@@ -11,6 +11,8 @@
"build:test:web": "nx build:test @notesnook/web",
"build:beta:web": "nx build:beta @notesnook/web",
"start:web": "nx start @notesnook/web",
"start:theme-builder": "nx start @notesnook/theme-builder",
"build:theme-builder": "nx build @notesnook/theme-builder",
"start:vericrypt": "nx start @notesnook/vericrypt",
"start:desktop": "nx start @notesnook/desktop",
"test:web": "nx test @notesnook/web",