├── .node-version ├── web ├── src │ ├── analytics.ts │ ├── styles │ │ ├── LandingPage.module.scss │ │ ├── App.module.scss │ │ ├── constants.scss │ │ ├── AuthPage.module.scss │ │ ├── NoteList.module.scss │ │ ├── index.scss │ │ ├── HomePage.module.scss │ │ ├── NavBar.module.scss │ │ └── CalculationsColumn.module.scss │ ├── declaration.d.ts │ ├── components │ │ ├── _newFunctionalComponentTemplate.tsx │ │ ├── _newClassComponentTemplate.tsx │ │ ├── Redirect.tsx │ │ ├── NoteList.tsx │ │ ├── NavBar.tsx │ │ ├── App.tsx │ │ └── CalculationsColumn.tsx │ ├── global.ts │ ├── pages │ │ ├── NotFoundPage.tsx │ │ ├── ResetPasswordPage.tsx │ │ ├── AboutPage.tsx │ │ ├── AuthPage.tsx │ │ └── HomePage.tsx │ ├── index.tsx │ ├── legacyLocalStorage.ts │ ├── template.html │ ├── types.ts │ ├── localSettings.ts │ ├── utils.ts │ ├── config.ts │ ├── api.ts │ ├── localNoteStorage.ts │ └── calculate.ts ├── static │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon-96x96.png │ ├── favicon-152x152.png │ ├── favicon-167x167.png │ ├── favicon-180x180.png │ ├── favicon-196x196.png │ ├── favicon-512x512.png │ ├── sitemap.xml │ ├── manifest.json │ └── index.html ├── package.json ├── webpack.config.js └── tsconfig.json ├── server ├── scripts │ ├── connectToLocal.sh │ ├── connectToProduction.sh │ └── randomCode.ts ├── src │ ├── typeDeclarations │ │ └── express.d.ts │ ├── config.ts │ ├── userUtils.ts │ ├── baseLib │ │ ├── database.ts │ │ └── auth.ts │ ├── dal │ │ ├── resetPasswordDAL.ts │ │ ├── userDAL.ts │ │ └── noteDAL.ts │ ├── routes │ │ ├── export.ts │ │ ├── auth.ts │ │ ├── passwordReset.ts │ │ └── api.ts │ └── server.ts ├── migrations │ ├── 20230120093634_createResetPasswordTable.ts │ ├── 20220906112729_createInitialTables.ts │ └── 20221125152106_addNoteStorageAndEmail.ts ├── tsconfig.json ├── knexfile.ts ├── webpack.config.js └── package.json ├── .gitignore ├── docs ├── screenshot.png └── advancedReadme.md ├── shared ├── utils.ts ├── types.ts ├── dateUtils.ts └── apiTypes.ts ├── dev.sh ├── .dockerignore ├── docker-dev.sh ├── cspell.json ├── config ├── notepadcalculator2.service ├── notepadcalculator2-nginx.conf └── copyToAppServer.sh ├── deploy.sh ├── Dockerfile ├── README.md ├── LICENSE.txt └── docker-compose.yml /.node-version: -------------------------------------------------------------------------------- 1 | 20.12.2 -------------------------------------------------------------------------------- /web/src/analytics.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/scripts/connectToLocal.sh: -------------------------------------------------------------------------------- 1 | psql -d notepadcalculator -U notepadcalculator -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | setProductionEnvVars.sh 5 | .productionEnvVars -------------------------------------------------------------------------------- /server/scripts/connectToProduction.sh: -------------------------------------------------------------------------------- 1 | psql -d notepadcalculator -U notepadcalculator -h postgres-1 -------------------------------------------------------------------------------- /docs/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveRidout/notepad-calculator/HEAD/docs/screenshot.png -------------------------------------------------------------------------------- /shared/utils.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => 2 | new Promise((resolve) => setTimeout(resolve, ms)); 3 | -------------------------------------------------------------------------------- /web/src/styles/LandingPage.module.scss: -------------------------------------------------------------------------------- 1 | @import "constants"; 2 | 3 | .body { 4 | background-color: green; 5 | } 6 | -------------------------------------------------------------------------------- /web/static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveRidout/notepad-calculator/HEAD/web/static/favicon-16x16.png -------------------------------------------------------------------------------- /web/static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveRidout/notepad-calculator/HEAD/web/static/favicon-32x32.png -------------------------------------------------------------------------------- /web/static/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveRidout/notepad-calculator/HEAD/web/static/favicon-96x96.png -------------------------------------------------------------------------------- /web/static/favicon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveRidout/notepad-calculator/HEAD/web/static/favicon-152x152.png -------------------------------------------------------------------------------- /web/static/favicon-167x167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveRidout/notepad-calculator/HEAD/web/static/favicon-167x167.png -------------------------------------------------------------------------------- /web/static/favicon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveRidout/notepad-calculator/HEAD/web/static/favicon-180x180.png -------------------------------------------------------------------------------- /web/static/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveRidout/notepad-calculator/HEAD/web/static/favicon-196x196.png -------------------------------------------------------------------------------- /web/static/favicon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SteveRidout/notepad-calculator/HEAD/web/static/favicon-512x512.png -------------------------------------------------------------------------------- /server/src/typeDeclarations/express.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Request { 3 | user?: { 4 | id: number; 5 | }; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /dev.sh: -------------------------------------------------------------------------------- 1 | node server/node_modules/.bin/multiexec \ 2 | "cd server && npx webpack -w" \ 3 | "cd web && npx webpack -w" \ 4 | "cd server && npx nodemon --enable-source-maps dist/serverBundle.js" -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | */node_modules 3 | */dist 4 | .git 5 | .gitignore 6 | *.log 7 | .DS_Store 8 | .env 9 | docker-compose.yml 10 | Dockerfile 11 | README.md 12 | deploy.sh 13 | config/ -------------------------------------------------------------------------------- /web/src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css" { 2 | const mapping: Record; 3 | export default mapping; 4 | } 5 | 6 | declare module "*.scss" { 7 | const mapping: Record; 8 | export default mapping; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | dbName: process.env.DB_NAME ?? "notepadcalculator", 3 | dbHost: process.env.DB_HOST ?? "postgres", 4 | dbPassword: process.env.DB_PASSWORD ?? "localdevpassword", 5 | dbUser: process.env.DB_USER ?? "notepadcalculator", 6 | }; 7 | -------------------------------------------------------------------------------- /web/src/components/_newFunctionalComponentTemplate.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent, h } from "preact"; 2 | 3 | import style from "../styles/Xxx.module.scss"; 4 | 5 | interface Props {} 6 | 7 | const Xxx: FunctionalComponent = ({ children }) => { 8 | return
{children}
; 9 | }; 10 | 11 | export default Xxx; 12 | -------------------------------------------------------------------------------- /server/src/userUtils.ts: -------------------------------------------------------------------------------- 1 | const googleIdPrefix = "G:"; 2 | 3 | export const getUserId = (googleId: string) => `${googleIdPrefix}${googleId}`; 4 | 5 | export const getGoogleId = (userId: string) => { 6 | if (!userId.startsWith(googleIdPrefix)) { 7 | throw Error("This user ID doesn't contain a google user ID"); 8 | } 9 | return userId.slice(2); 10 | }; 11 | -------------------------------------------------------------------------------- /web/src/styles/App.module.scss: -------------------------------------------------------------------------------- 1 | @import "./constants.scss"; 2 | 3 | .page { 4 | display: flex; 5 | flex-direction: column; 6 | position: fixed; 7 | inset: 0; 8 | max-width: 1200px; 9 | margin: 0 auto; 10 | 11 | &.dark-mode { 12 | color: $color-text-dark; 13 | background-color: $color-background-dark; 14 | } 15 | 16 | @media (max-width: $max-mobile-width) { 17 | position: relative; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | export interface WholeDate { 2 | year: number; 3 | month: number; 4 | day: number; 5 | } 6 | 7 | export interface User { 8 | id: number; 9 | username: string; 10 | hashedPassword: string; 11 | creationTime: Date; 12 | } 13 | 14 | export interface Note { 15 | id: number; 16 | // userId: number; 17 | title: string; 18 | body: string; 19 | creationTime: Date; 20 | lastModifiedTime: Date; 21 | } 22 | -------------------------------------------------------------------------------- /web/static/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://notepadcalculator.com/ 5 | Weekly 6 | 1 7 | 8 | 9 | https://notepadcalculator.com/about 10 | Weekly 11 | 0.7 12 | 13 | -------------------------------------------------------------------------------- /web/src/global.ts: -------------------------------------------------------------------------------- 1 | // Global state. Using this lightweight solution for now until potentially switching to Redux. 2 | 3 | interface GlobalState { 4 | lastSeasonVisited?: number; 5 | lastPhotoSetVisited?: number; 6 | } 7 | 8 | let state: GlobalState = {}; 9 | 10 | export const getState = () => state; 11 | 12 | export const updateState = (update: Partial) => { 13 | state = { 14 | ...state, 15 | ...update, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /web/src/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent, h } from "preact"; 2 | 3 | import style from "../styles/HomePage.module.scss"; 4 | 5 | const NotFoundPage: FunctionalComponent = () => { 6 | return ( 7 |
8 |

Page Not Found

9 |

10 | Return Home 11 |

12 |
13 | ); 14 | }; 15 | 16 | export default NotFoundPage; 17 | -------------------------------------------------------------------------------- /server/migrations/20230120093634_createResetPasswordTable.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from "knex"; 2 | 3 | export const up = async (knex: Knex): Promise => { 4 | await knex.schema.createTable("reset_password", (table) => { 5 | table.text("code").primary(); 6 | table.integer("user_id").references("user.id"); 7 | }); 8 | }; 9 | 10 | export const down = async (knex: Knex): Promise => { 11 | await knex.schema.dropTable("reset_password"); 12 | }; 13 | -------------------------------------------------------------------------------- /web/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { h, render } from "preact"; 2 | 3 | import style from "./styles/index.scss"; 4 | import App from "./components/App"; 5 | import * as localSettings from "./localSettings"; 6 | 7 | const rootElement = document.getElementById("root"); 8 | 9 | if (localSettings.darkMode()) { 10 | document.body.classList.add(style["dark-mode"]); 11 | } 12 | 13 | if (rootElement) { 14 | render(, rootElement); 15 | } else { 16 | console.error("No root element"); 17 | } 18 | -------------------------------------------------------------------------------- /server/scripts/randomCode.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | 3 | const numbers = _.range(10); 4 | const upperCaseLetters = _.range(65, 91).map((n) => String.fromCharCode(n)); 5 | const lowerCaseLetters = _.range(97, 123).map((n) => String.fromCharCode(n)); 6 | 7 | const allCharacters = [...numbers, ...upperCaseLetters, ...lowerCaseLetters]; 8 | 9 | const code = _.range(10) 10 | .map(() => allCharacters[_.random(0, allCharacters.length - 1)]) 11 | .join(""); 12 | 13 | console.log(code); 14 | -------------------------------------------------------------------------------- /server/migrations/20220906112729_createInitialTables.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from "knex"; 2 | 3 | export const up = async (knex: Knex): Promise => { 4 | await knex.schema.createTable("user", (table) => { 5 | table.increments("id"); 6 | table.text("username").unique(); 7 | table.text("hashed_password"); 8 | table.datetime("creation_time"); 9 | }); 10 | }; 11 | 12 | export const down = async (knex: Knex): Promise => { 13 | await knex.schema.dropTable("user"); 14 | }; 15 | -------------------------------------------------------------------------------- /web/static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Notepad Calculator", 3 | "short_name": "Notepad Calculator", 4 | "icons": [ 5 | { 6 | "src": "/favicon-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/favicon-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "theme_color": "#000000", 19 | "background_color": "#ffffff" 20 | } 21 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | // "baseUrl": ".", // This must be specified if "paths" is. 5 | "lib": ["ES2021"], 6 | "module": "CommonJS", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "strict": true, 10 | "target": "ES2021", 11 | "esModuleInterop": true 12 | }, 13 | "include": ["scripts/**/*", "src/**/*", "tests/**/*", "knexfile.ts"], 14 | "files": ["src/typeDeclarations/express.d.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /web/src/components/_newClassComponentTemplate.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h } from "preact"; 2 | 3 | import style from "../styles/Xxx.module.scss"; 4 | 5 | interface Props {} 6 | 7 | interface State {} 8 | 9 | class Xxx extends Component { 10 | constructor(props: Props) { 11 | super(props); 12 | this.state = {}; 13 | } 14 | 15 | render() { 16 | const {} = this.props; 17 | const {} = this.state; 18 | 19 | return
; 20 | } 21 | } 22 | 23 | export default Xxx; 24 | -------------------------------------------------------------------------------- /docker-dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Install dependencies 4 | echo "Installing dependencies..." 5 | cd web && npm install --prune 6 | cd ../server && npm install --prune 7 | cd .. 8 | 9 | # Wait for database to be ready 10 | echo "Waiting for database..." 11 | while ! nc -z postgres 5432; do 12 | sleep 1 13 | done 14 | echo "Database is ready!" 15 | 16 | # Run migrations 17 | echo "Running database migrations..." 18 | cd server && npx knex migrate:latest 19 | cd .. 20 | 21 | # Start the development server 22 | exec sh dev.sh -------------------------------------------------------------------------------- /web/src/legacyLocalStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is responsible for migrating data created with the old version of Notepad Calculator to the 3 | * new version 4 | */ 5 | 6 | const oldNoteKey = "notePadValue"; 7 | 8 | /** Returns the value of the notepad stored by the old legacy version of the webapp */ 9 | export const getValue = () => { 10 | return localStorage[oldNoteKey]; 11 | }; 12 | 13 | /** Clears the value of the notepad stored by the old legacy version of the webapp */ 14 | export const clear = async () => { 15 | localStorage.removeItem(oldNoteKey); 16 | }; 17 | -------------------------------------------------------------------------------- /web/src/components/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "preact"; 2 | import { route } from "preact-router"; 3 | 4 | interface Props { 5 | to: string; 6 | } 7 | 8 | export default class Redirect extends Component { 9 | componentWillMount() { 10 | console.log("redirect props: ", this.props); 11 | 12 | // XXX For some reason the following commented-out line doesn't work: 13 | // route(this.props.to, true); 14 | // 15 | // So going with this instead: 16 | window.location.pathname = "/"; 17 | } 18 | 19 | render() { 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /web/src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <% preact.title %> 6 | 7 | 8 | 9 | 10 | 11 | <% preact.headEnd %> 12 | 13 | 14 | <% preact.bodyEnd %> 15 | 16 | 17 | -------------------------------------------------------------------------------- /server/src/baseLib/database.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Allows access to the Postgres DB. 3 | */ 4 | 5 | import { Knex, knex } from "knex"; 6 | 7 | import config from "../config"; 8 | 9 | let knexInstance: Knex | undefined; 10 | 11 | export const getKnex = (): Knex => { 12 | if (knexInstance) { 13 | return knexInstance; 14 | } 15 | knexInstance = knex({ 16 | client: "pg", 17 | connection: { 18 | host: config.dbHost, 19 | database: config.dbName, 20 | user: config.dbUser, 21 | password: config.dbPassword, 22 | }, 23 | }); 24 | return knexInstance; 25 | }; 26 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | // cSpell Settings 2 | { 3 | // Version of the setting file. Always 0.2 4 | "version": "0.2", 5 | // language - current active spelling language 6 | "language": "en", 7 | // words - list of words to be always considered correct 8 | "words": [ 9 | "datetime", 10 | "fileupload", 11 | "preact", 12 | "ridout", 13 | "templating", 14 | "debouncer" 15 | ], 16 | // flagWords - list of words to be always considered incorrect 17 | // This is useful for offensive words and common spelling errors. 18 | // For example "hte" should be "the" 19 | "flagWords": ["hte"] 20 | } 21 | -------------------------------------------------------------------------------- /server/knexfile.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from "knex"; 2 | 3 | const knexConfig: { [key: string]: Knex.Config } = { 4 | development: { 5 | client: "pg", 6 | connection: { 7 | host: process.env.DB_HOST || "postgres", 8 | database: process.env.DB_NAME || "notepadcalculator", 9 | user: process.env.DB_USER || "notepadcalculator", 10 | password: process.env.DB_PASSWORD || "localdevpassword", 11 | }, 12 | pool: { 13 | min: 2, 14 | max: 10, 15 | }, 16 | migrations: { 17 | tableName: "knex_migrations", 18 | }, 19 | }, 20 | }; 21 | 22 | module.exports = knexConfig; -------------------------------------------------------------------------------- /config/notepadcalculator2.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Notepad Calculator Server 3 | Documentation=https://notepadcalculator.com 4 | After=network.target 5 | 6 | [Service] 7 | Environment="NODE_PORT=11420" 8 | Environment="HOST=https://notepadcalculator.com" 9 | Environment="NODE_ENV=production" 10 | Type=simple 11 | User=steve 12 | WorkingDirectory=/var/www/notepadcalculator2/server 13 | ExecStart=/home/steve/.nodenv/shims/node dist/serverBundle.js 14 | Restart=on-failure 15 | StandardOutput=append:/var/www/notepadcalculator2/server-stdout.log 16 | StandardError=append:/var/www/notepadcalculator2/server-stderr.log 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | -------------------------------------------------------------------------------- /server/src/dal/resetPasswordDAL.ts: -------------------------------------------------------------------------------- 1 | import { getKnex } from "../baseLib/database"; 2 | import { getUser } from "./userDAL"; 3 | 4 | interface ResetPasswordRow { 5 | code: string; 6 | user_id: number; 7 | } 8 | 9 | const resetPasswordTableName = "reset_password"; 10 | const resetPasswordTable = () => 11 | getKnex().table(resetPasswordTableName); 12 | 13 | export const getUserId = async (code: string): Promise => { 14 | const rawRows = await resetPasswordTable().select().where({ 15 | code, 16 | }); 17 | 18 | return rawRows[0]?.user_id; 19 | }; 20 | 21 | export const deleteCode = async (code: string) => { 22 | await resetPasswordTable().delete().where({ code }); 23 | }; 24 | -------------------------------------------------------------------------------- /server/webpack.config.js: -------------------------------------------------------------------------------- 1 | const nodeExternals = require("webpack-node-externals"); 2 | const path = require("path"); 3 | 4 | const mode = 5 | process.env.NODE_ENV === "production" ? "production" : "development"; 6 | 7 | module.exports = { 8 | devtool: "source-map", 9 | entry: ["./src/server.ts"], 10 | externals: nodeExternals(), 11 | mode, 12 | output: { 13 | filename: "serverBundle.js", 14 | path: path.resolve(__dirname, "dist"), 15 | }, 16 | resolve: { 17 | extensions: ["", ".ts", ".js"], 18 | }, 19 | module: { 20 | rules: [ 21 | { 22 | test: /\.ts$/, 23 | use: "ts-loader", 24 | exclude: /node_modules/, 25 | }, 26 | ], 27 | }, 28 | target: "node", 29 | }; 30 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script builds and deploys to the production server 4 | 5 | source setProductionEnvVars.sh 6 | 7 | cd server 8 | NODE_ENV=production npx webpack 9 | cd ../web 10 | NODE_ENV=production npx webpack 11 | cd .. 12 | 13 | rsync -r --delete server/dist steve@app-1:/var/www/notepadcalculator2/server 14 | rsync -r --delete server/package.json steve@app-1:/var/www/notepadcalculator2/server 15 | rsync -r --delete server/package-lock.json steve@app-1:/var/www/notepadcalculator2/server 16 | rsync -r --delete web/dist steve@app-1:/var/www/notepadcalculator2/web 17 | scp .node-version steve@app-1:/var/www/notepadcalculator2/.node-version 18 | 19 | echo "" 20 | echo "Manually run npm install and update notepadcalculator2.service if necessary" 21 | -------------------------------------------------------------------------------- /web/src/styles/constants.scss: -------------------------------------------------------------------------------- 1 | $max-small-desktop-width: 700px; 2 | $max-mobile-width: 600px; 3 | 4 | $unit: 10px; 5 | 6 | $color-background: #fff; 7 | $color-cta: #24f; 8 | $color-answer: rgb(233, 152, 0); 9 | $color-answer-changed: rgb(233, 66, 0); 10 | $color-ruler: #f1d0a2; 11 | $color-warning: #e44; 12 | $color-selected-background: rgb(245, 200, 117); 13 | $color-text: #000; 14 | $color-comment: #2daf16; 15 | 16 | $color-background-dark: #222; 17 | $color-cta-dark: rgb(125, 159, 252); 18 | $color-answer-dark: rgb(254, 191, 73); 19 | $color-answer-changed-dark: rgb(246, 83, 19); 20 | $color-ruler-dark: #6f4a15; 21 | $color-warning-dark: rgb(238, 87, 87); 22 | $color-selected-background-dark: rgb(113, 75, 5); 23 | $color-text-dark: #fff; 24 | $color-comment-dark: #4fd637; 25 | -------------------------------------------------------------------------------- /web/src/styles/AuthPage.module.scss: -------------------------------------------------------------------------------- 1 | @import "./constants.scss"; 2 | 3 | .auth-page-main-content { 4 | margin: $unit auto; 5 | width: 50%; 6 | min-width: 300px; 7 | } 8 | 9 | .auth-form { 10 | table { 11 | width: 100%; 12 | 13 | td { 14 | padding: 2px 0; 15 | } 16 | 17 | td:first-child { 18 | padding-right: 2 * $unit; 19 | width: 33%; 20 | } 21 | } 22 | 23 | input:not([type="checkbox"]) { 24 | padding: 6px 8px; 25 | margin-left: 4px; 26 | width: 100%; 27 | font-size: 16px; 28 | } 29 | 30 | input[type="checkbox"] { 31 | width: 20px; 32 | height: 20px; 33 | } 34 | 35 | button { 36 | padding: $unit; 37 | width: 100%; 38 | font-size: 18px; 39 | } 40 | } 41 | 42 | .error-message { 43 | color: $color-warning; 44 | font-weight: 700; 45 | } 46 | -------------------------------------------------------------------------------- /server/migrations/20221125152106_addNoteStorageAndEmail.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from "knex"; 2 | 3 | export const up = async (knex: Knex): Promise => { 4 | await knex.schema.createTable("note", (table) => { 5 | table.increments("id"); 6 | table.integer("user_id").references("user.id"); 7 | table.text("title"); 8 | table.text("body"); 9 | table.datetime("creation_time"); 10 | table.datetime("last_modified_time"); 11 | 12 | table.index(["user_id", "title"]); 13 | }); 14 | 15 | await knex.schema.createTable("note_order", (table) => { 16 | table.integer("user_id").references("user.id").primary(); 17 | table.specificType("note_ids", "integer ARRAY"); 18 | }); 19 | }; 20 | 21 | export const down = async (knex: Knex): Promise => { 22 | await knex.schema.dropTable("note_order"); 23 | await knex.schema.dropTable("note"); 24 | }; 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.12.2-alpine 2 | 3 | # Install build dependencies for bcrypt 4 | RUN apk add --no-cache python3 make g++ py3-pip 5 | 6 | WORKDIR /app 7 | 8 | # Copy package files 9 | COPY server/package*.json ./server/ 10 | COPY web/package*.json ./web/ 11 | 12 | # Install dependencies 13 | RUN cd server && npm ci 14 | RUN cd web && npm ci 15 | 16 | # Copy dev scripts first 17 | COPY dev.sh ./ 18 | COPY docker-dev.sh ./ 19 | 20 | # Copy source code 21 | COPY server ./server 22 | COPY web ./web 23 | COPY shared ./shared 24 | 25 | # Install multiexec globally and netcat for database health check 26 | RUN npm install -g multiexec && apk add --no-cache netcat-openbsd 27 | 28 | # Make scripts executable 29 | RUN chmod +x docker-dev.sh dev.sh 30 | 31 | # Expose the application port 32 | EXPOSE 4002 33 | 34 | # Use the docker dev script by default 35 | CMD ["sh", "docker-dev.sh"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notepad Calculator 2 | 3 | Mix free form text notes with calculations. This is a NodeJS responsive web-app with user accounts, allowing you to create and access your notes from any mobile or desktop browser. 4 | 5 | Use it at [notepadcalculator.com](https://notepadcalculator.com). 6 | 7 | ![screenshot showing notepadcalculator on mobile](docs/screenshot.png) 8 | 9 | This was originally inspired by Soulver, an app for iOS and mac that I hadn't even used myself, but I liked the concept. 10 | 11 | ## How to run locally 12 | 13 | Prerequisite: Docker 27 14 | 15 | ```sh 16 | docker compose up 17 | ``` 18 | 19 | The site will now be running on your local machine at http://localhost:4002 20 | 21 | ## More info 22 | 23 | If you want to run it yourself without Docker, either locally or in production, they you are welcome to try but I can't offer you any support. That said, you can read my own notes in [advancedReadme.md](docs/advancedReadme.md). 24 | -------------------------------------------------------------------------------- /web/src/styles/NoteList.module.scss: -------------------------------------------------------------------------------- 1 | @import "./constants.scss"; 2 | 3 | .note-list { 4 | list-style: none; 5 | padding: 0; 6 | margin: $unit 0; 7 | 8 | &.dark-mode { 9 | .note { 10 | color: $color-text-dark; 11 | 12 | a { 13 | color: $color-text-dark; 14 | } 15 | } 16 | 17 | .selected-note { 18 | background-color: $color-selected-background-dark; 19 | } 20 | } 21 | } 22 | 23 | .note { 24 | cursor: pointer; 25 | color: $color-text; 26 | font-weight: 700; 27 | padding: 8px; 28 | border-radius: 4px; 29 | 30 | a { 31 | text-decoration: none; 32 | color: $color-text; 33 | width: 100%; 34 | } 35 | } 36 | 37 | .selected-note { 38 | background-color: $color-selected-background; 39 | } 40 | 41 | .new-note-button { 42 | font-weight: 700; 43 | display: inline-block; 44 | padding-top: 15px; 45 | 46 | @media (max-width: $max-mobile-width) { 47 | padding-top: 10px; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /web/src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "constants"; 2 | 3 | html, 4 | body { 5 | height: 100%; 6 | width: 100%; 7 | padding: 0; 8 | margin: 0; 9 | background: #fff; 10 | font-family: "Helvetica Neue", arial, sans-serif; 11 | font-weight: 400; 12 | font-size: 18px; 13 | color: $color-text; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | 17 | // This is necessary to avoid the textarea formatting going out of sync with the overlay 18 | -webkit-text-size-adjust: none; 19 | 20 | @media (max-width: $max-small-desktop-width) { 21 | font-size: 16px; 22 | } 23 | 24 | &.dark-mode { 25 | background: $color-background-dark; 26 | color: $color-text-dark; 27 | 28 | a { 29 | color: $color-cta-dark; 30 | } 31 | } 32 | } 33 | 34 | * { 35 | box-sizing: border-box; 36 | } 37 | 38 | a { 39 | color: $color-cta; 40 | cursor: pointer; 41 | } 42 | 43 | ul { 44 | padding-left: 25px; 45 | } 46 | -------------------------------------------------------------------------------- /web/src/types.ts: -------------------------------------------------------------------------------- 1 | import * as mathjs from "mathjs"; 2 | 3 | import { Note } from "../../shared/types"; 4 | 5 | /** Interface for storing notes, to be extended by concrete local and remote implementations */ 6 | export interface NoteStorageAPI { 7 | getNote: (id: number) => Promise; 8 | 9 | saveNote: (note: { title: string; body: string }) => Promise; 10 | 11 | updateNote: (id: number, update: { title?: string; body?: string }) => void; 12 | 13 | deleteNote: (id: number) => void; 14 | 15 | listNoteIds: () => Promise; 16 | 17 | saveNoteIds: (ids: number[]) => void; 18 | 19 | /** Set to true if the user has edited these notes. */ 20 | setDirty?: (dirty: boolean) => void; 21 | 22 | /** Returns true if the user has edited these notes. */ 23 | isDirty?: () => Promise; 24 | 25 | clear?: () => void; 26 | } 27 | 28 | export type MathValue = number | mathjs.Unit; 29 | export type Scope = { [variableName: string]: MathValue | MathValue[] | mathjs.Matrix}; 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Steve Ridout 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /server/src/routes/export.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import archiver from "archiver"; 3 | import { Response } from "express"; 4 | 5 | // Create a router 6 | const router = express.Router(); 7 | 8 | // Function to create a zip file and pipe it to the response 9 | const sendZipFile = (res: Response) => { 10 | const archive = archiver("zip", { 11 | zlib: { level: 9 }, // Compression level 12 | }); 13 | 14 | archive.on("error", function (err) { 15 | throw err; 16 | }); 17 | 18 | // Set the archive name 19 | res.attachment("files.zip"); 20 | 21 | // Pipe the zip to the response 22 | archive.pipe(res); 23 | 24 | // Append files to the zip 25 | archive.append("hello from file 1", { name: "file1.txt" }); 26 | archive.append("hello from file 2", { name: "file2.txt" }); 27 | archive.append("hello from file 3", { name: "file3.txt" }); 28 | 29 | // Finalize the archive 30 | archive.finalize(); 31 | }; 32 | 33 | // GET /download route 34 | router.get("/download", (req, res) => { 35 | sendZipFile(res); 36 | }); 37 | 38 | export default router; 39 | -------------------------------------------------------------------------------- /shared/dateUtils.ts: -------------------------------------------------------------------------------- 1 | import { WholeDate } from "./types"; 2 | 3 | export const monthDisplayString = [ 4 | "January", 5 | "February", 6 | "March", 7 | "April", 8 | "May", 9 | "June", 10 | "July", 11 | "August", 12 | "September", 13 | "October", 14 | "November", 15 | "December", 16 | ]; 17 | 18 | export const HOUR = 1000 * 60 * 60; 19 | export const DAY = 24 * HOUR; 20 | export const YEAR = 365.25 * DAY; 21 | 22 | export const dateToUTCWholeDate = (date: Date): WholeDate => ({ 23 | year: date.getUTCFullYear(), 24 | month: date.getUTCMonth() + 1, 25 | day: date.getUTCDate(), 26 | }); 27 | 28 | export const wholeDateToUTCDate = (wholeDate: WholeDate): Date => 29 | new Date(Date.UTC(wholeDate.year, wholeDate.month - 1, wholeDate.day)); 30 | 31 | export const secondsSinceEpoch = (date: Date) => 32 | Math.floor(date.getTime() / 1000); 33 | 34 | export const wholeDatesEqual = (a: WholeDate, b: WholeDate) => 35 | a.year === b.year && a.month === b.month && a.day === b.day; 36 | 37 | export const dateToMonthDisplayString = (date: Date) => 38 | monthDisplayString[date.getMonth()]; 39 | -------------------------------------------------------------------------------- /config/notepadcalculator2-nginx.conf: -------------------------------------------------------------------------------- 1 | # This block redirects http traffic to https 2 | server { 3 | if ($host = notepadcalculator.com) { 4 | return 301 https://$host$request_uri; 5 | } # managed by Certbot 6 | 7 | listen 80; 8 | listen [::]:80; 9 | server_name notepadcalculator.com; 10 | 11 | return 301 https://$host$request_uri; 12 | } 13 | 14 | server { 15 | listen 443 ssl; 16 | server_name notepadcalculator.com; 17 | 18 | ssl_certificate /etc/letsencrypt/live/notepadcalculator.com/fullchain.pem; # managed by Certbot 19 | ssl_certificate_key /etc/letsencrypt/live/notepadcalculator.com/privkey.pem; # managed by Certbot 20 | 21 | location ~ ^/s/[0-9a-f]+$ { 22 | return 302 http://old.notepadcalculator.com$request_uri; 23 | } 24 | 25 | location / { 26 | proxy_pass http://127.0.0.1:11420; 27 | } 28 | } 29 | 30 | # For testing 31 | server { 32 | listen 80; 33 | server_name test.notepadcalculator.com; 34 | 35 | location ~ ^/s/[0-9a-f]+$ { 36 | return 302 http://old.notepadcalculator.com$request_uri; 37 | } 38 | 39 | location / { 40 | proxy_pass http://127.0.0.1:11420; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /web/src/localSettings.ts: -------------------------------------------------------------------------------- 1 | /** localStorage based settings flags */ 2 | 3 | /** Should be a locale supported as an argument to Number.prototype.toLocaleString() */ 4 | export const locale = () => localStorage["locale"]; 5 | 6 | export const isLocaleValid = (value: string | undefined) => { 7 | const testNumber = 0; 8 | try { 9 | // This will throw an error if value is not a valid locale: 10 | testNumber.toLocaleString(value); 11 | return true; 12 | } catch (_error) { 13 | return false; 14 | } 15 | } 16 | 17 | /** This will throw an error if @param value is not a valid locale */ 18 | export const setLocale = (value: string) => { 19 | if (value === "") { 20 | localStorage.removeItem("locale"); 21 | return; 22 | } 23 | 24 | if (isLocaleValid(value)) { 25 | localStorage.setItem("locale", value); 26 | } 27 | } 28 | 29 | export const toggleDarkMode = () => { 30 | localStorage["darkMode"] = JSON.stringify(darkMode() ? false : true); 31 | } 32 | 33 | /** Should be true or false */ 34 | export const darkMode = () => { 35 | try { 36 | return JSON.parse(localStorage["darkMode"]); 37 | } catch { 38 | return false; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /web/src/utils.ts: -------------------------------------------------------------------------------- 1 | /** For creating class name strings */ 2 | export const cn = (list: (string | undefined)[]) => 3 | list.filter((item) => item !== null && item !== undefined).join(" "); 4 | 5 | /** 6 | * This manages multiple groups of debounced function calls. Function calls with the same 7 | * given debounceKey will be debounced together within the same group. 8 | */ 9 | export class MultiDebouncer { 10 | private keyToTimeoutId: { [key: string]: ReturnType } = {}; 11 | 12 | /** Milliseconds to delay function call */ 13 | private delay: number; 14 | 15 | constructor(delay: number) { 16 | this.delay = delay; 17 | } 18 | 19 | private cancelGroup(key: string) { 20 | const timeoutId = this.keyToTimeoutId[key]; 21 | if (timeoutId === undefined) { 22 | return; 23 | } 24 | clearTimeout(timeoutId); 25 | delete this.keyToTimeoutId[key]; 26 | } 27 | 28 | call(callback: () => void, debounceKey: string) { 29 | this.cancelGroup(debounceKey); 30 | 31 | const timeoutId = setTimeout(() => { 32 | callback(); 33 | this.cancelGroup(debounceKey); 34 | }, this.delay); 35 | 36 | this.keyToTimeoutId[debounceKey] = timeoutId; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:14-alpine 6 | environment: 7 | POSTGRES_USER: notepadcalculator 8 | POSTGRES_PASSWORD: localdevpassword 9 | POSTGRES_DB: notepadcalculator 10 | ports: 11 | - "5433:5432" 12 | volumes: 13 | - postgres_data:/var/lib/postgresql/data 14 | healthcheck: 15 | test: ["CMD-SHELL", "pg_isready -U notepadcalculator"] 16 | interval: 5s 17 | timeout: 5s 18 | retries: 5 19 | 20 | app: 21 | build: . 22 | ports: 23 | - "4002:4002" 24 | environment: 25 | NODE_ENV: development 26 | NODE_PORT: 4002 27 | DB_HOST: postgres 28 | DB_NAME: notepadcalculator 29 | DB_USER: notepadcalculator 30 | DB_PASSWORD: localdevpassword 31 | depends_on: 32 | postgres: 33 | condition: service_healthy 34 | volumes: 35 | - ./server:/app/server 36 | - ./web:/app/web 37 | - ./shared:/app/shared 38 | - ./docker-dev.sh:/app/docker-dev.sh 39 | - /app/server/node_modules 40 | - /app/web/node_modules 41 | command: sh -c "cd /app && sh docker-dev.sh" 42 | stdin_open: true 43 | tty: true 44 | 45 | volumes: 46 | postgres_data: -------------------------------------------------------------------------------- /server/src/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import passport from "passport"; 3 | 4 | import { PostUserResponse, PostUserRequest } from "../../../shared/apiTypes"; 5 | 6 | import * as auth from "../baseLib/auth"; 7 | 8 | const router = express.Router(); 9 | 10 | router.post<"/user", {}, PostUserResponse, PostUserRequest>( 11 | "/user", 12 | express.json(), 13 | async (req, res) => { 14 | const { username, password } = req.body; 15 | const addUserReturnValue /*: PostUserResponse*/ = await auth.addUser( 16 | username, 17 | password 18 | ); 19 | 20 | switch (addUserReturnValue.type) { 21 | case "user-already-exists": 22 | res.json(addUserReturnValue); 23 | return; 24 | 25 | case "user-created": 26 | req.login({ id: addUserReturnValue.id }, () => { 27 | res.json(addUserReturnValue); 28 | }); 29 | return; 30 | 31 | default: 32 | throw Error("Unexpected error"); 33 | } 34 | } 35 | ); 36 | 37 | router.post( 38 | "/login", 39 | express.json(), 40 | passport.authenticate("local"), 41 | function (_req, res) { 42 | res.send("Login successful"); 43 | } 44 | ); 45 | 46 | router.post("/logout", async (req, res) => { 47 | req.logout(); 48 | res.send(); 49 | }); 50 | 51 | export default router; 52 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notepad-calculator-backend", 3 | "version": "1.0.0", 4 | "description": "Web-app to create freeform text notes with calculations", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "@types/archiver": "^6.0.2", 13 | "@types/bcrypt": "5.0.0", 14 | "@types/cookie-session": "2.0.44", 15 | "@types/express": "4.17.13", 16 | "@types/express-fileupload": "1.2.2", 17 | "@types/lodash": "4.14.178", 18 | "@types/passport-local": "1.0.34", 19 | "image-size": "1.0.1", 20 | "knex": "1.0.3", 21 | "multiexec": "0.0.3", 22 | "nodemon": "2.0.15", 23 | "sourcemap-lookup": "0.0.5", 24 | "ts-loader": "9.2.6", 25 | "ts-node": "10.4.0", 26 | "typescript": "4.5.2", 27 | "webpack": "5.67.0", 28 | "webpack-cli": "4.9.2", 29 | "webpack-node-externals": "3.0.0" 30 | }, 31 | "dependencies": { 32 | "archiver": "^6.0.1", 33 | "axios": "0.25.0", 34 | "bcrypt": "5.0.1", 35 | "cookie-session": "2.0.0", 36 | "express": "4.17.2", 37 | "express-fileupload": "1.3.1", 38 | "lodash": "4.17.21", 39 | "passport": "0.5.2", 40 | "passport-local": "1.0.0", 41 | "pg": "8.16.2", 42 | "qs": "6.10.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /shared/apiTypes.ts: -------------------------------------------------------------------------------- 1 | import * as types from "./types"; 2 | 3 | export interface UserResponse { 4 | user: types.User; 5 | } 6 | 7 | export interface UpdateUserRequest { 8 | username: string; 9 | } 10 | 11 | export interface PostUserRequest { 12 | username: string; 13 | password: string; 14 | } 15 | 16 | export type PostUserResponse = 17 | | { 18 | type: "user-created"; 19 | id: number; 20 | } 21 | | { 22 | type: "user-already-exists"; 23 | }; 24 | 25 | export interface UpdatePasswordRequest { 26 | oldPassword: string; 27 | newPassword: string; 28 | } 29 | 30 | export interface ResetPasswordRequest { 31 | resetCode: string; 32 | newPassword: string; 33 | } 34 | 35 | export interface UserFromResetCodeResponse { 36 | username: string; 37 | } 38 | 39 | // Notes 40 | 41 | export interface GetNoteResponse { 42 | note: types.Note; 43 | } 44 | 45 | export interface PostNoteRequest { 46 | noteData: { 47 | title: string; 48 | body: string; 49 | }; 50 | } 51 | 52 | export interface PostNoteResponse { 53 | note: types.Note; 54 | } 55 | 56 | export interface UpdateNoteRequest { 57 | update: { 58 | title?: string; 59 | body?: string; 60 | }; 61 | } 62 | 63 | export interface UpdateNoteResponse { 64 | note: types.Note; 65 | } 66 | 67 | export interface ListNoteIdsResponse { 68 | noteIds: number[]; 69 | } 70 | 71 | export interface PostOrderedNoteIdsRequest { 72 | noteIds: number[]; 73 | } 74 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "notepad-calculator-frontend", 4 | "version": "1.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "build": "preact build", 8 | "serve": "sirv build --port 8080 --cors --single", 9 | "dev": "preact watch", 10 | "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", 11 | "test": "jest ./tests" 12 | }, 13 | "eslintConfig": { 14 | "parser": "@typescript-eslint/parser", 15 | "extends": [ 16 | "preact", 17 | "plugin:@typescript-eslint/recommended" 18 | ], 19 | "ignorePatterns": [ 20 | "build/" 21 | ] 22 | }, 23 | "dependencies": { 24 | "@types/gapi": "^0.0.41", 25 | "axios": "0.25.0", 26 | "lodash": "4.17.21", 27 | "mathjs": "15.0.0", 28 | "preact": "^10.3.1", 29 | "preact-render-to-string": "^5.1.4", 30 | "preact-router": "^4.1" 31 | }, 32 | "devDependencies": { 33 | "@types/lodash": "4.14.178", 34 | "@typescript-eslint/eslint-plugin": "^2.25.0", 35 | "@typescript-eslint/parser": "^2.25.0", 36 | "copy-webpack-plugin": "^10.2.1", 37 | "eslint": "^6.8.0", 38 | "eslint-config-preact": "^1.1.1", 39 | "html-webpack-plugin": "^5.5.0", 40 | "preact-cli": "^3.0.0", 41 | "sass": "^1.49.0", 42 | "sass-loader": "^10.2.1", 43 | "sirv-cli": "^1.0.0-next.3", 44 | "style-loader": "^3.3.1", 45 | "ts-loader": "^9.2.6", 46 | "typescript": "^4.5.2", 47 | "webpack": "^5.67.0", 48 | "webpack-cli": "^4.9.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /web/src/styles/HomePage.module.scss: -------------------------------------------------------------------------------- 1 | @import "./constants.scss"; 2 | 3 | .about-page-main-content { 4 | margin: $unit auto; 5 | min-width: 300px; 6 | width: 100%; 7 | padding: 0 25%; 8 | overflow-y: auto; 9 | 10 | @media (max-width: $max-mobile-width) { 11 | padding: 0 $unit; 12 | } 13 | } 14 | 15 | .footer { 16 | flex: 0 0 auto; 17 | margin: $unit; 18 | text-align: center; 19 | } 20 | 21 | .main-container { 22 | display: flex; 23 | flex-direction: row; 24 | gap: $unit; 25 | align-items: stretch; 26 | margin: $unit; 27 | flex: 1 1 auto; 28 | overflow: hidden; 29 | 30 | @media (max-width: $max-mobile-width) { 31 | overflow: visible; 32 | } 33 | } 34 | 35 | .note-list-column { 36 | flex: 0 0 auto; 37 | width: 25%; 38 | max-width: 200px; 39 | line-height: 1.4; 40 | overflow-y: auto; 41 | } 42 | 43 | .add-notes-hint { 44 | margin-top: $unit; 45 | line-height: 1.4; 46 | } 47 | 48 | .invalid { 49 | color: $color-warning; 50 | } 51 | 52 | .locale-input { 53 | padding: 4px; 54 | 55 | &.dark-mode { 56 | color: $color-text-dark; 57 | background-color: $color-background-dark; 58 | border: $color-text-dark 1px solid; 59 | 60 | &:focus { 61 | border-color: $color-cta-dark; 62 | outline: none; 63 | box-shadow: 0 0 0 1px $color-cta-dark; 64 | } 65 | } 66 | } 67 | 68 | .reset-password-table { 69 | button, 70 | input { 71 | padding: 8px; 72 | font-size: 17px; 73 | } 74 | 75 | td { 76 | padding: 4px; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /web/webpack.config.js: -------------------------------------------------------------------------------- 1 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 2 | const path = require("path"); 3 | 4 | const mode = 5 | process.env.NODE_ENV === "production" ? "production" : "development"; 6 | 7 | module.exports = { 8 | devtool: "source-map", 9 | entry: ["./src/index.tsx"], 10 | mode, 11 | output: { 12 | filename: "bundle.js", 13 | path: path.resolve(__dirname, "dist"), 14 | }, 15 | resolve: { 16 | extensions: ["", ".ts", ".tsx", ".js"], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.tsx?$/, 22 | use: "ts-loader", 23 | exclude: /node_modules/, 24 | }, 25 | { 26 | test: /\.s?css$/i, 27 | use: [ 28 | "style-loader", 29 | { 30 | loader: "css-loader", 31 | options: { 32 | modules: { 33 | localIdentName: 34 | mode === "development" 35 | ? "[path][name]__[local]" 36 | : "[hash:base64]", 37 | }, 38 | }, 39 | }, 40 | "sass-loader", 41 | ], 42 | }, 43 | ], 44 | }, 45 | plugins: [ 46 | new CopyWebpackPlugin({ 47 | patterns: [ 48 | // { 49 | // from: "static/index.html", 50 | // to: "index.html", 51 | // }, 52 | { 53 | from: "static", 54 | to: "", 55 | }, 56 | ], 57 | }), 58 | ], 59 | stats: { 60 | assets: false, 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /web/src/components/NoteList.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent, h } from "preact"; 2 | 3 | import style from "../styles/NoteList.module.scss"; 4 | import { Note } from "../../../shared/types"; 5 | import * as localSettings from "../localSettings"; 6 | 7 | export interface Props { 8 | noteIds: number[]; 9 | selectedNoteId: number; 10 | notes: { [id: number]: Note }; 11 | selectNote: (noteId: number) => void; 12 | addNote: () => void; 13 | } 14 | 15 | const NoteList: FunctionalComponent = ({ 16 | noteIds, 17 | selectedNoteId, 18 | notes, 19 | selectNote, 20 | addNote, 21 | }) => { 22 | return ( 23 |
    29 |
  • 30 | { 33 | addNote(); 34 | }} 35 | > 36 | + New Note 37 | 38 |
  • 39 | {noteIds.length > 0 ?
  •  
  • : null} 40 | {[...noteIds].reverse().map((noteId) => ( 41 |
  • selectNote(noteId)} 47 | > 48 | {notes[noteId].title.length > 0 ? notes[noteId].title : "New Note"} 49 |
  • 50 | ))} 51 |
52 | ); 53 | }; 54 | 55 | export default NoteList; 56 | -------------------------------------------------------------------------------- /web/src/styles/NavBar.module.scss: -------------------------------------------------------------------------------- 1 | @import "./constants.scss"; 2 | 3 | .nav-bar { 4 | display: flex; 5 | flex-direction: row; 6 | justify-content: space-between; 7 | 8 | flex: 0 0 auto; 9 | margin: $unit; 10 | padding-bottom: $unit; 11 | border-bottom: 1px solid $color-text; 12 | 13 | @media (max-width: $max-small-desktop-width) { 14 | margin-bottom: 0; 15 | } 16 | 17 | a { 18 | text-decoration: none; 19 | vertical-align: middle; 20 | } 21 | 22 | .logo { 23 | font-weight: 700; 24 | } 25 | 26 | .guest-container { 27 | position: relative; 28 | 29 | &:hover { 30 | .guest-tooltip { 31 | display: block; 32 | } 33 | } 34 | } 35 | 36 | .nav-bar-group { 37 | color: $color-text; 38 | 39 | a:not(.sign-up) { 40 | color: $color-text; 41 | } 42 | } 43 | 44 | .guest-tooltip { 45 | position: absolute; 46 | border: 1px solid #000; 47 | background-color: #fff; 48 | padding: $unit; 49 | width: 300px; 50 | right: -50px; 51 | top: calc(100% + 5px); 52 | display: none; 53 | 54 | h3, p { 55 | &:first-child { 56 | margin-top: 0; 57 | } 58 | 59 | &:last-child { 60 | margin-bottom: 0; 61 | } 62 | } 63 | } 64 | 65 | &.dark-mode { 66 | border-bottom-color: #fff; 67 | 68 | .guest-tooltip { 69 | background-color: #fff; 70 | } 71 | 72 | .nav-bar-group { 73 | color: $color-text-dark; 74 | 75 | a:not(.sign-up) { 76 | color: $color-text-dark; 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /web/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Notepad Calculator 6 | 10 | 14 | 15 | 16 | 17 | 18 | 24 | 30 | 31 | 32 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /web/src/config.ts: -------------------------------------------------------------------------------- 1 | export const exampleNote = { 2 | title: "Welcome Note", 3 | body: `Notepad meet Calculator 4 | ----------------------- 5 | 6 | - mix text with sums 7 | - a sane way to do multi-step calculations 8 | - sign up for free to sync across devices 9 | 10 | 11 | Examples 12 | -------- 13 | 14 | Edit these, or delete them all and start from scratch... 15 | 16 | # Example 1: Time 17 | 18 | How many weeks in a month? 19 | 52 / 12 20 | 21 | How many weekdays in a month? 22 | * 5 23 | 24 | # Example 2: Monitor specs (demonstrates variables) 25 | 26 | Use variables to represent full HD resolution: 27 | horizontal = 1920 28 | vertical = 1080 29 | 30 | Calculate total pixels: 31 | pixels = horizontal * vertical 32 | 33 | Basic trigonometry to figure out the pixel density: 34 | inches = 23 35 | dpi = sqrt(horizontal^2 + vertical^2) / inches 36 | 37 | # Example 3: Accounting (demonstrates lists) 38 | 39 | This example demonstrates the use of lists, let's create a list of income sources: 40 | 4000 # salary 41 | 600 # investment income 42 | 43 | You can refer to the previous list of consecutive numbers using a special variable called above: 44 | income = above 45 | sum(income) 46 | 47 | Now let's create a list of expenses: 48 | 1500 # rent 49 | 350 # utility bills 50 | 210 # council tax 51 | 400 # groceries 52 | 300 # meals out 53 | 600 # travel and other spending 54 | 55 | expenses = above 56 | sum(expenses) 57 | 58 | amountSaved = sum(income) - sum(expenses) 59 | fractionSaved = amountSaved / sum(income) 60 | 61 | 62 | Attributions 63 | ------------ 64 | 65 | Calculations performed by https://mathjs.org 66 | 67 | Created by https://steveridout.com` 68 | }; 69 | 70 | /** This needs to match $max-mobile-width in constants.scss */ 71 | export const maxMobileWidth = 600; 72 | -------------------------------------------------------------------------------- /server/src/baseLib/auth.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcrypt"; 2 | import passport from "passport"; 3 | import { Strategy as LocalStrategy } from "passport-local"; 4 | 5 | import * as apiTypes from "../../../shared/apiTypes"; 6 | 7 | import * as userDAL from "../dal/userDAL"; 8 | 9 | export const auth = () => { 10 | passport.serializeUser((user, done) => done(null, user)); 11 | passport.deserializeUser((user: any, done) => done(null, user)); 12 | 13 | passport.use( 14 | "local", 15 | new LocalStrategy( 16 | { 17 | usernameField: "username", 18 | passwordField: "password", 19 | }, 20 | async (username, password, done) => { 21 | const user = await userDAL.getUser({ username }); 22 | const correct = 23 | user && (await checkPassword(password, user.hashedPassword)); 24 | 25 | if (correct) { 26 | done(null, { id: user.id }); 27 | } else { 28 | done(null, false); 29 | } 30 | } 31 | ) 32 | ); 33 | }; 34 | 35 | export const addUser = async ( 36 | username: string, 37 | password: string 38 | ): Promise => { 39 | if (password.length < 4) { 40 | throw Error("Invalid password"); 41 | } 42 | 43 | const hashedPassword = await createPasswordHash(password); 44 | 45 | return await userDAL.addUser({ 46 | username, 47 | hashedPassword, 48 | creationTime: new Date(), 49 | }); 50 | }; 51 | 52 | /** Create salted password hash. */ 53 | export const createPasswordHash = async (password: string) => { 54 | const salt = await bcrypt.genSalt(10); 55 | const hash = await bcrypt.hash(password, salt); 56 | return hash; 57 | }; 58 | 59 | /** Check if password is correct. */ 60 | export const checkPassword = async (password: string, hash: string) => { 61 | return await bcrypt.compare(password, hash); 62 | }; 63 | -------------------------------------------------------------------------------- /server/src/routes/passwordReset.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import * as _ from "lodash"; 3 | 4 | import * as apiTypes from "../../../shared/apiTypes"; 5 | import * as userDAL from "../dal/userDAL"; 6 | import * as resetPasswordDAL from "../dal/resetPasswordDAL"; 7 | 8 | import * as auth from "../baseLib/auth"; 9 | 10 | const router = express.Router(); 11 | 12 | router.post<{}, {}, apiTypes.ResetPasswordRequest>( 13 | "/newPassword", 14 | express.json(), 15 | async (req, res) => { 16 | const userId = await resetPasswordDAL.getUserId(req.body.resetCode); 17 | 18 | if (userId === undefined) { 19 | res.status(404).send("Reset code not valid"); 20 | return; 21 | } 22 | 23 | const user = await userDAL.getUser({ id: userId }); 24 | if (!user) { 25 | res.status(404).send("User not found"); 26 | return; 27 | } 28 | 29 | if (req.body.newPassword.length < 6) { 30 | res.status(400).send("New password is too short"); 31 | return; 32 | } 33 | 34 | const hashedPassword = await auth.createPasswordHash(req.body.newPassword); 35 | 36 | await userDAL.updateHashedPassword(userId, hashedPassword); 37 | await resetPasswordDAL.deleteCode(req.body.resetCode); 38 | 39 | res.send({}); 40 | } 41 | ); 42 | 43 | router.get< 44 | "/user/:resetCode", 45 | { resetCode: string }, 46 | { username: string } | string 47 | >("/user/:resetCode", async (req, res) => { 48 | const userId = await resetPasswordDAL.getUserId(req.params.resetCode); 49 | 50 | if (userId === undefined) { 51 | res.status(404).send("Reset code not found"); 52 | return; 53 | } 54 | 55 | const user = await userDAL.getUser({ id: userId }); 56 | 57 | if (user === undefined) { 58 | res.status(404).send("User not found"); 59 | return; 60 | } 61 | 62 | res.json({ 63 | username: user?.username, 64 | }); 65 | }); 66 | 67 | export default router; 68 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cookieSession from "cookie-session"; 3 | import passport from "passport"; 4 | import path from "path"; 5 | 6 | import { auth } from "./baseLib/auth"; 7 | import apiRouter from "./routes/api"; 8 | import authRouter from "./routes/auth"; 9 | import passwordResetRouter from "./routes/passwordReset"; 10 | 11 | console.log("process.env.NODE_ENV: ", process.env.NODE_ENV); 12 | console.log("process.env.HOST: ", process.env.HOST); 13 | 14 | const app = express(); 15 | 16 | // Set up OAuth 2.0 authentication through the passport.js library. 17 | auth(); 18 | 19 | const isAuthenticated: express.RequestHandler = (req, res, next) => { 20 | if (req.user?.id !== undefined) { 21 | return next(); 22 | } 23 | 24 | res.status(401).send("Not authenticated"); 25 | }; 26 | 27 | app.use( 28 | cookieSession({ 29 | name: "session", 30 | /* cspell: disable-next-line */ 31 | keys: ["j8ZHQy7CYQkYkNYAjUg8E7Ld"], 32 | maxAge: 2 * 365 * 24 * 60 * 60 * 1000, // 2 years 33 | }) 34 | ); 35 | 36 | const slowMode = process.env.NODE_ENV === "development"; 37 | 38 | // Set up passport and session handling. 39 | app.use(passport.initialize()); 40 | app.use(passport.session()); 41 | 42 | app.use("/auth", authRouter); 43 | app.use( 44 | "/api", 45 | isAuthenticated, 46 | (_req, _res, next) => { 47 | if (!slowMode) { 48 | next(); 49 | return; 50 | } 51 | 52 | setTimeout(() => { 53 | next(); 54 | }, 500); 55 | }, 56 | apiRouter 57 | ); 58 | app.use("/passwordReset", passwordResetRouter); 59 | app.use(express.static("../web/dist/")); 60 | 61 | const port = process.env["NODE_PORT"] ?? 4002; 62 | 63 | app.get("*", (_req, res) => { 64 | res.sendFile("index.html", { 65 | root: path.resolve(__dirname, "../../web/dist"), 66 | }); 67 | }); 68 | 69 | app.listen(port, () => { 70 | console.log(`app listening on port ${port}`); 71 | }); 72 | -------------------------------------------------------------------------------- /config/copyToAppServer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if .productionEnvVars file exists 4 | if [ ! -f "../.productionEnvVars" ]; then 5 | echo "ERROR: Missing .productionEnvVars file" 6 | echo "" 7 | echo "Please create the file '.productionEnvVars' in the project root with the following format:" 8 | echo "" 9 | echo " DB_NAME=your_database_name" 10 | echo " DB_HOST=your_database_host" 11 | echo " DB_PASSWORD=your_database_password" 12 | echo " DB_USER=your_database_user" 13 | echo "" 14 | echo "At minimum, DB_HOST and DB_PASSWORD must be provided." 15 | exit 1 16 | fi 17 | 18 | # Create a temporary copy of the service file 19 | TMP_SERVICE_FILE="/tmp/notepadcalculator2.service.tmp" 20 | cp notepadCalculator2.service "$TMP_SERVICE_FILE" 21 | 22 | # Read environment variables from .productionEnvVars and inject them into the service file 23 | ENV_LINES="" 24 | while IFS='=' read -r key value || [ -n "$key" ]; do 25 | # Skip empty lines and comments 26 | [[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue 27 | 28 | # Trim whitespace 29 | key=$(echo "$key" | xargs) 30 | value=$(echo "$value" | xargs) 31 | 32 | if [ -n "$key" ] && [ -n "$value" ]; then 33 | if [ -n "$ENV_LINES" ]; then 34 | ENV_LINES="${ENV_LINES}\n" 35 | fi 36 | ENV_LINES="${ENV_LINES}Environment=\"${key}=${value}\"" 37 | fi 38 | done < "../.productionEnvVars" 39 | 40 | # Inject the environment variables after the [Service] line 41 | if [ -n "$ENV_LINES" ]; then 42 | # Use awk to inject after [Service] line 43 | awk -v env_vars="$ENV_LINES" ' 44 | /^\[Service\]$/ { 45 | print 46 | gsub(/\\n/, "\n", env_vars) 47 | printf "%s\n", env_vars 48 | next 49 | } 50 | {print} 51 | ' "$TMP_SERVICE_FILE" > "${TMP_SERVICE_FILE}.new" 52 | mv "${TMP_SERVICE_FILE}.new" "$TMP_SERVICE_FILE" 53 | fi 54 | 55 | # Copy the modified service file to the server 56 | scp "$TMP_SERVICE_FILE" steve@app-1:/lib/systemd/system/notepadcalculator2.service 57 | 58 | # Clean up the temporary file 59 | rm "$TMP_SERVICE_FILE" 60 | 61 | # Copy nginx config as before 62 | scp notepadCalculator2-nginx.conf steve@app-1:/etc/nginx/sites-enabled/notepadcalculator2.conf 63 | 64 | echo "Remember to restart nginx and notepadcalculator2 services on the server:" 65 | echo "" 66 | echo "ssh steve@app-1" 67 | echo "sudo systemctl daemon-reload" 68 | echo "sudo systemctl restart nginx" 69 | echo "sudo systemctl restart notepadcalculator2" -------------------------------------------------------------------------------- /server/src/dal/userDAL.ts: -------------------------------------------------------------------------------- 1 | import { User } from "../../../shared/types"; 2 | import { PostUserResponse } from "../../../shared/apiTypes"; 3 | 4 | import { getKnex } from "../baseLib/database"; 5 | 6 | interface UserRow { 7 | id: number; 8 | username: string; 9 | hashed_password: string; 10 | creation_time: Date; 11 | } 12 | 13 | const userTableName = "user"; 14 | const userTable = () => getKnex().table(userTableName); 15 | 16 | const parseUser = (userRow: UserRow): User => ({ 17 | id: userRow.id, 18 | username: userRow.username, 19 | hashedPassword: userRow.hashed_password, 20 | creationTime: userRow.creation_time, 21 | }); 22 | 23 | export const getUser = async ( 24 | query: { id: number } | { username: string } 25 | ): Promise => { 26 | const userRow = (await userTable().select().where(query))[0] as UserRow; 27 | return userRow === undefined ? userRow : parseUser(userRow); 28 | }; 29 | 30 | export const getUsers = async (ids: number[]): Promise => { 31 | const userRows = (await userTable().select().whereIn("id", ids)) as UserRow[]; 32 | return userRows.map((userRow) => parseUser(userRow)); 33 | }; 34 | 35 | export const addUser = async ( 36 | user: Omit 37 | ): Promise => { 38 | let id: number; 39 | try { 40 | id = ( 41 | (await userTable() 42 | .insert({ 43 | username: user.username, 44 | hashed_password: user.hashedPassword, 45 | creation_time: user.creationTime, 46 | }) 47 | .returning("id")) as { id: number }[] 48 | )[0].id; 49 | } catch (error) { 50 | console.error("ERROR: ", error); 51 | 52 | const postgresUniqueViolationErrorCode = 23505; 53 | 54 | if ( 55 | parseInt((error as any)?.code, 10) === postgresUniqueViolationErrorCode 56 | ) { 57 | console.log("User already exists"); 58 | return { 59 | type: "user-already-exists", 60 | }; 61 | } 62 | 63 | throw error; 64 | } 65 | 66 | return { 67 | type: "user-created", 68 | id, 69 | }; 70 | }; 71 | 72 | export const updateUser = async ( 73 | id: number, 74 | user: { username: string } 75 | ): Promise => { 76 | await userTable() 77 | .update({ 78 | username: user.username, 79 | }) 80 | .where({ id }); 81 | }; 82 | 83 | export const updateHashedPassword = async ( 84 | id: number, 85 | hashedPassword: string 86 | ): Promise => { 87 | await userTable() 88 | .update({ 89 | hashed_password: hashedPassword, 90 | }) 91 | .where({ id }); 92 | }; 93 | -------------------------------------------------------------------------------- /web/src/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent, h } from "preact"; 2 | 3 | import * as api from "../api"; 4 | import style from "../styles/NavBar.module.scss"; 5 | import { User } from "../../../shared/types"; 6 | import * as localSettings from "../localSettings"; 7 | 8 | interface Props { 9 | user: User | undefined; 10 | loading?: boolean; 11 | isMobile: boolean; 12 | } 13 | 14 | interface LinkProps { 15 | pathname: string; 16 | targetBlank?: boolean; 17 | } 18 | 19 | const Link: FunctionalComponent = ({ 20 | pathname, 21 | targetBlank = false, 22 | children, 23 | }) => { 24 | return ( 25 | 33 | {children} 34 | 35 | ); 36 | }; 37 | 38 | const NavBar: FunctionalComponent = ({ loading, user, isMobile }) => { 39 | return ( 40 |
46 |
47 | Notepad Calculator 48 | {!isMobile ?   |   : null} 49 | {!isMobile ? About : null} 50 | {user && !isMobile ?   |   : null} 51 | {user && !isMobile ? ( 52 | 53 | Export 54 | 55 | ) : null} 56 |
57 | {loading ? ( 58 |
Loading...
59 | ) : ( 60 |
61 | {user ? ( 62 | {user.username} 63 | ) : ( 64 | 65 | Login 66 | 67 | )} 68 |   |   69 | {user ? ( 70 | { 73 | await api.logout(); 74 | window.location.reload(); 75 | }} 76 | > 77 | Logout 78 | 79 | ) : ( 80 | 85 | Sign up{!isMobile ? " for free" : null} 86 | 87 | )} 88 |
89 | )} 90 |
91 | ); 92 | }; 93 | 94 | export default NavBar; 95 | -------------------------------------------------------------------------------- /server/src/dal/noteDAL.ts: -------------------------------------------------------------------------------- 1 | import { Note } from "../../../shared/types"; 2 | 3 | import { getKnex } from "../baseLib/database"; 4 | 5 | interface NoteRow { 6 | id: number; 7 | user_id: number; 8 | title: string; 9 | body: string; 10 | creation_time: Date; 11 | last_modified_time: Date; 12 | } 13 | 14 | const noteTableName = "note"; 15 | const noteTable = () => getKnex().table(noteTableName); 16 | 17 | interface NoteIdsRow { 18 | user_id: number; 19 | note_ids: number[]; 20 | } 21 | 22 | const noteOrderTableName = "note_order"; 23 | const noteOrderTable = () => getKnex().table(noteOrderTableName); 24 | 25 | const parseNote = (noteRow: NoteRow): Note & { userId: number } => ({ 26 | id: noteRow.id, 27 | userId: noteRow.user_id, 28 | title: noteRow.title, 29 | body: noteRow.body, 30 | creationTime: noteRow.creation_time, 31 | lastModifiedTime: noteRow.last_modified_time, 32 | }); 33 | 34 | export const getNote = async ( 35 | userId: number, 36 | noteId: number 37 | ): Promise => { 38 | const rawNote = ( 39 | await noteTable().select().where({ 40 | user_id: userId, 41 | id: noteId, 42 | }) 43 | )[0] as NoteRow | undefined; 44 | 45 | if (rawNote === undefined) { 46 | return undefined; 47 | } 48 | 49 | return parseNote(rawNote); 50 | }; 51 | 52 | export const saveNote = async ( 53 | userId: number, 54 | noteData: { 55 | title: string; 56 | body: string; 57 | } 58 | ): Promise => { 59 | const currentTime = new Date(); 60 | 61 | const insertResult = (await noteTable() 62 | .insert({ 63 | user_id: userId, 64 | ...noteData, 65 | creation_time: currentTime, 66 | last_modified_time: currentTime, 67 | }) 68 | .returning("id")) as { id: number }[]; 69 | 70 | const note = await getNote(userId, insertResult[0].id); 71 | 72 | if (note === undefined) { 73 | throw Error("Note not defined"); 74 | } 75 | 76 | return note; 77 | }; 78 | 79 | export const updateNote = async ( 80 | userId: number, 81 | noteId: number, 82 | update: { title?: string; body?: string } 83 | ): Promise => { 84 | const currentTime = new Date(); 85 | 86 | await noteTable() 87 | .update({ 88 | ...update, 89 | last_modified_time: currentTime, 90 | }) 91 | // Redundant fields here, but doesn't do any harm 92 | .where({ user_id: userId, id: noteId }); 93 | 94 | const note = await getNote(userId, noteId); 95 | 96 | if (note === undefined) { 97 | throw Error("Note not found"); 98 | } 99 | 100 | return note; 101 | }; 102 | 103 | export const deleteNote = async (userId: number, noteId: number) => { 104 | await noteTable().delete().where({ user_id: userId, id: noteId }); 105 | }; 106 | 107 | export const renameNote = async ( 108 | userId: number, 109 | oldTitle: string, 110 | newTitle: string 111 | ) => { 112 | await noteTable() 113 | .update({ title: newTitle }) 114 | .where({ user_id: userId, title: oldTitle }); 115 | }; 116 | 117 | export const listNoteIds = async ( 118 | userId: number 119 | ): Promise => { 120 | const rawList = ( 121 | await noteOrderTable().select().limit(1).where({ user_id: userId }) 122 | )[0] as NoteIdsRow; 123 | 124 | return rawList?.note_ids; 125 | }; 126 | 127 | export const saveNoteIds = async ( 128 | userId: number, 129 | noteIds: number[] 130 | ): Promise => { 131 | await noteOrderTable() 132 | .insert({ 133 | user_id: userId, 134 | note_ids: noteIds, 135 | }) 136 | .onConflict("user_id") 137 | .merge(); 138 | }; 139 | -------------------------------------------------------------------------------- /web/src/pages/ResetPasswordPage.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent, h } from "preact"; 2 | import { useState } from "preact/hooks"; 3 | 4 | import style from "../styles/HomePage.module.scss"; 5 | import * as api from "../api"; 6 | 7 | interface Props { 8 | resetCode?: string; 9 | } 10 | 11 | const ResetPasswordPage: FunctionalComponent = ({ resetCode }) => { 12 | const [username, setUsername] = useState(); 13 | const [errorMessage, setErrorMessage] = useState(); 14 | const [password1, setPassword1] = useState(""); 15 | const [password2, setPassword2] = useState(""); 16 | 17 | if (resetCode === undefined) { 18 | return
ERROR: no reset code
; 19 | } 20 | 21 | if (username === undefined) { 22 | api 23 | .getUsernameFromResetCode(resetCode) 24 | .then((username) => { 25 | setUsername(username); 26 | }) 27 | .catch(() => { 28 | setErrorMessage("Reset code not valid"); 29 | }); 30 | } 31 | 32 | if (errorMessage) { 33 | return ( 34 |
35 | ERROR: this reset password link has expired 36 |
37 | ); 38 | } 39 | 40 | if (username === undefined) { 41 | return
Loading...
; 42 | } 43 | 44 | return ( 45 |
46 |
47 |

Reset password for user "{username}"

48 | 49 | 50 | 51 | 60 | 61 | 62 | 63 | 71 | 72 | 73 | 74 | 103 | 104 |
Password: 52 | setPassword1(event.currentTarget.value)} 56 | value={password1} 57 | autoFocus={true} 58 | > 59 |
Repeat Password: 64 | setPassword2(event.currentTarget.value)} 68 | value={password2} 69 | > 70 |
75 | 102 |
105 |
106 |
107 | ); 108 | }; 109 | 110 | export default ResetPasswordPage; 111 | -------------------------------------------------------------------------------- /web/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h } from "preact"; 2 | import { Router } from "preact-router"; 3 | 4 | import { User } from "../../../shared/types"; 5 | 6 | import AboutPage from "../pages/AboutPage"; 7 | import AuthPage from "../pages/AuthPage"; 8 | import HomePage from "../pages/HomePage"; 9 | import NotFoundPage from "../pages/NotFoundPage"; 10 | import ResetPasswordPage from "../pages/ResetPasswordPage"; 11 | import Redirect from "./Redirect"; 12 | import NavBar from "./NavBar"; 13 | import * as api from "../api"; 14 | import style from "../styles/App.module.scss"; 15 | import * as config from "../config"; 16 | import * as localSettings from "../localSettings"; 17 | 18 | interface Props {} 19 | 20 | interface State { 21 | user: User | undefined; 22 | loading: boolean; 23 | windowWidth: number; 24 | } 25 | 26 | class App extends Component { 27 | constructor(props: Props) { 28 | super(props); 29 | 30 | this.state = { 31 | loading: true, 32 | user: undefined, 33 | windowWidth: window.innerWidth, 34 | }; 35 | 36 | this.fetchUser(); 37 | } 38 | 39 | async fetchUser() { 40 | let user: User | undefined; 41 | 42 | try { 43 | user = (await api.getUser()).user; 44 | } catch (error) { 45 | user = undefined; 46 | } 47 | 48 | this.setState({ 49 | ...this.state, 50 | user, 51 | loading: false, 52 | }); 53 | } 54 | 55 | loggedOut() { 56 | this.setState({ ...this.state, user: undefined }); 57 | } 58 | 59 | render() { 60 | const { user, loading } = this.state; 61 | 62 | const isMobile = this.state.windowWidth <= config.maxMobileWidth; 63 | 64 | if (loading) { 65 | return ( 66 |
72 | 73 |
74 | ); 75 | } 76 | 77 | if (user === undefined) { 78 | return ( 79 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 |
96 | ); 97 | } 98 | 99 | return ( 100 |
106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 |
117 | ); 118 | } 119 | 120 | onWindowResize = () => { 121 | this.setState({ windowWidth: window.innerWidth }); 122 | }; 123 | 124 | componentDidMount(): void { 125 | window.addEventListener("resize", this.onWindowResize); 126 | } 127 | 128 | componentWillUnmount(): void { 129 | window.removeEventListener("resize", this.onWindowResize); 130 | } 131 | } 132 | 133 | export default App; 134 | -------------------------------------------------------------------------------- /web/src/api.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse } from "axios"; 2 | import * as _ from "lodash"; 3 | 4 | import * as apiTypes from "../../shared/apiTypes"; 5 | import { Note } from "../../shared/types"; 6 | import { NoteStorageAPI } from "./types"; 7 | 8 | // === Start Auth === 9 | 10 | /** Returns true for success or false for failure. */ 11 | export const login = async ( 12 | username: string, 13 | password: string 14 | ): Promise => { 15 | let result; 16 | try { 17 | result = await axios.post("/auth/login", { 18 | username, 19 | password, 20 | }); 21 | } catch (error) { 22 | console.log("caught error: ", error); 23 | return false; 24 | } 25 | 26 | return result.status === 200; 27 | }; 28 | 29 | export const signUp = async ( 30 | username: string, 31 | password: string 32 | ): Promise => { 33 | return ( 34 | await axios.post("/auth/user", { 35 | username, 36 | password, 37 | }) 38 | ).data; 39 | }; 40 | 41 | export const logout = async () => await axios.post("/auth/logout"); 42 | 43 | // === End Auth === 44 | 45 | // === Start User === 46 | 47 | export const getUser = async () => { 48 | return (await axios.get("/api/user")).data as apiTypes.UserResponse; 49 | }; 50 | 51 | export const updateUser = async (update: { username: string }) => { 52 | await axios.post<{}, {}, apiTypes.UpdateUserRequest>( 53 | "/api/user/update", 54 | update 55 | ); 56 | }; 57 | 58 | export const updatePassword = async ( 59 | oldPassword: string, 60 | newPassword: string 61 | ) => { 62 | await axios.post<{}, {}, apiTypes.UpdatePasswordRequest>( 63 | "/api/user/updatePassword", 64 | { 65 | oldPassword, 66 | newPassword, 67 | } 68 | ); 69 | }; 70 | 71 | // === End User === 72 | 73 | // === Password reset === 74 | 75 | export const getUsernameFromResetCode = async ( 76 | resetCode: string 77 | ): Promise => { 78 | const responseBody = (await axios.get(`/passwordReset/user/${resetCode}`)) 79 | .data as apiTypes.UserFromResetCodeResponse; 80 | 81 | return responseBody.username; 82 | }; 83 | 84 | export const resetPassword = async ( 85 | resetCode: string, 86 | newPassword: string 87 | ): Promise<{ succeeded: boolean }> => { 88 | const body: apiTypes.ResetPasswordRequest = { resetCode, newPassword }; 89 | const response = await axios.post(`/passwordReset/newPassword`, body); 90 | 91 | return { succeeded: response.status === 200 }; 92 | }; 93 | 94 | // === End Password reset === 95 | 96 | // === Start Notes === 97 | 98 | export const remoteNoteStorage: NoteStorageAPI = { 99 | getNote: async (id: number) => { 100 | const { note } = ( 101 | await axios.get(`/api/note/${id}`) 102 | ).data; 103 | return note; 104 | }, 105 | 106 | updateNote: async ( 107 | id: number, 108 | update: { 109 | title?: string; 110 | body?: string; 111 | } 112 | ) => { 113 | await axios.post< 114 | apiTypes.UpdateNoteResponse, 115 | AxiosResponse, 116 | apiTypes.UpdateNoteRequest 117 | >(`/api/note/${id}/update`, { update }); 118 | }, 119 | 120 | saveNote: async (noteData: { 121 | title: string; 122 | body: string; 123 | }): Promise => { 124 | const { note } = ( 125 | await axios.post< 126 | apiTypes.PostNoteResponse, 127 | AxiosResponse, 128 | apiTypes.PostNoteRequest 129 | >(`/api/note`, { noteData }) 130 | ).data; 131 | 132 | return note; 133 | }, 134 | 135 | deleteNote: async (id: number) => { 136 | await axios.post(`/api/note/${id}/delete`); 137 | }, 138 | 139 | listNoteIds: async (): Promise => { 140 | const { noteIds } = ( 141 | await axios.get("/api/noteIds") 142 | ).data; 143 | return noteIds; 144 | }, 145 | 146 | saveNoteIds: async (noteIds: number[]) => { 147 | await axios.post(`/api/noteIds`, { noteIds }); 148 | }, 149 | }; 150 | 151 | // === End Notes === 152 | -------------------------------------------------------------------------------- /web/src/localNoteStorage.ts: -------------------------------------------------------------------------------- 1 | /** This stores the notes either via browser local storage or (eventually TODO!) via the JSON API */ 2 | import { NoteStorageAPI } from "./types"; 3 | import { Note } from "../../shared/types"; 4 | 5 | /** Like Note but stores dates as integers for storing in localStorage */ 6 | interface RawNote { 7 | id: number; 8 | title: string; 9 | body: string; 10 | 11 | /** Milliseconds since epoch */ 12 | creationTime: number; 13 | 14 | /** Milliseconds since epoch */ 15 | lastModifiedTime: number; 16 | } 17 | 18 | const parseNote = (rawNote: RawNote): Note => ({ 19 | id: rawNote.id, 20 | title: rawNote.title, 21 | body: rawNote.body, 22 | creationTime: new Date(rawNote.creationTime), 23 | lastModifiedTime: new Date(rawNote.creationTime), 24 | }); 25 | 26 | /** Local storage key for the note data */ 27 | const noteKey = (id: number) => `notes/${id}`; 28 | 29 | /** Local storage key for the ordered list of note IDs */ 30 | const noteListKey = "noteList"; 31 | 32 | /** Local storage key for the next auto-incrementing note ID */ 33 | const nextNoteIdKey = "nextNodeId"; 34 | 35 | /** 36 | * This is set to true if the user has made any edit to a note, used to determine whether to show 37 | * the "upload notes to cloud" option at sign up. 38 | */ 39 | const localNotesDirtyKey = "notesDirty"; 40 | 41 | /** Returns the next node ID and auto-increments the value */ 42 | const getNextNodeIdKeyAndIncrement = async () => { 43 | const rawId = localStorage[nextNoteIdKey]; 44 | const id = rawId === undefined ? 0 : parseInt(rawId, 10); 45 | 46 | localStorage[nextNoteIdKey] = id + 1; 47 | 48 | return id; 49 | }; 50 | 51 | const localNotesStorage: NoteStorageAPI = { 52 | getNote: async (id: number): Promise => { 53 | const rawData = localStorage[noteKey(id)]; 54 | return rawData ? parseNote(JSON.parse(rawData)) : undefined; 55 | }, 56 | 57 | listNoteIds: async (): Promise => { 58 | const rawData = localStorage[noteListKey]; 59 | return rawData ? JSON.parse(rawData) : []; 60 | }, 61 | 62 | saveNote: async ({ 63 | title, 64 | body, 65 | }: { 66 | title: string; 67 | body: string; 68 | }): Promise => { 69 | const currentTime = new Date().getTime(); 70 | 71 | const rawNote: RawNote = { 72 | id: await getNextNodeIdKeyAndIncrement(), 73 | title, 74 | body, 75 | creationTime: currentTime, 76 | lastModifiedTime: currentTime, 77 | }; 78 | 79 | localStorage[noteKey(rawNote.id)] = JSON.stringify(rawNote); 80 | const noteIds = await localNotesStorage.listNoteIds(); 81 | await localNotesStorage.saveNoteIds([...(noteIds ?? []), rawNote.id]); 82 | 83 | localStorage[localNotesDirtyKey] = JSON.stringify(true); 84 | 85 | localNotesStorage.setDirty?.(true); 86 | 87 | return parseNote(rawNote); 88 | }, 89 | 90 | saveNoteIds: async (ids: number[]) => { 91 | localStorage[noteListKey] = JSON.stringify(ids); 92 | }, 93 | 94 | deleteNote: async (id: number) => { 95 | localStorage.removeItem(noteKey(id)); 96 | const noteIds = await localNotesStorage.listNoteIds(); 97 | 98 | await localNotesStorage.saveNoteIds( 99 | noteIds.filter((thisId) => thisId !== id) 100 | ); 101 | }, 102 | 103 | updateNote: async (id: number, update: { title?: string; body?: string }) => { 104 | const note = await localNotesStorage.getNote(id); 105 | if (note === undefined) { 106 | throw Error("Note not found"); 107 | } 108 | const updatedNote: Note = { 109 | ...note, 110 | ...update, 111 | }; 112 | 113 | const updatedRawNote: RawNote = { 114 | id, 115 | title: updatedNote.title, 116 | body: updatedNote.body, 117 | lastModifiedTime: new Date().getTime(), 118 | creationTime: updatedNote.creationTime?.getTime(), 119 | }; 120 | 121 | localStorage[noteKey(id)] = JSON.stringify(updatedRawNote); 122 | 123 | localNotesStorage.setDirty?.(true); 124 | }, 125 | 126 | setDirty: async (dirty: boolean) => { 127 | localStorage[localNotesDirtyKey] = JSON.stringify(dirty); 128 | }, 129 | 130 | isDirty: async () => { 131 | const rawDirty = localStorage[localNotesDirtyKey]; 132 | return rawDirty !== undefined && JSON.parse(rawDirty); 133 | }, 134 | 135 | clear: async () => { 136 | localStorage.clear(); 137 | }, 138 | }; 139 | 140 | export default localNotesStorage; 141 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */, 5 | "module": "ESNext" /* Specify module code generation: 'none', commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation: */ 7 | "allowJs": true /* Allow javascript files to be compiled. */, 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 10 | "jsxFactory": "h" /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */, 11 | "jsxFragmentFactory": "Fragment" /* Specify the JSX fragment factory function to use when targeting react JSX emit with jsxFactory compiler option is specified, e.g. Fragment. */, 12 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 13 | "sourceMap": true /* Generates corresponding '.map' file. */, 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | // "outDir": "./", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": false, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true /* Enable all strict type-checking options. */, 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 28 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 29 | 30 | /* Additional Checks */ 31 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 32 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 33 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 34 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 35 | 36 | /* Module Resolution Options */ 37 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 38 | "esModuleInterop": true /* */, 39 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 40 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 41 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 42 | // "typeRoots": [], /* List of folders to include type definitions from. */ 43 | // "types": [], /* Type declaration files to be included in compilation. */ 44 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 45 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 46 | 47 | /* Source Map Options */ 48 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 49 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 50 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 51 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 52 | 53 | /* Experimental Options */ 54 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 55 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 56 | 57 | /* Advanced Options */ 58 | "skipLibCheck": true /* Skip type checking of declaration files. */ 59 | }, 60 | "include": ["src/**/*", "tests/**/*"] 61 | } 62 | -------------------------------------------------------------------------------- /web/src/pages/AboutPage.tsx: -------------------------------------------------------------------------------- 1 | import { FunctionalComponent, h } from "preact"; 2 | import { useState } from "preact/hooks"; 3 | 4 | import style from "../styles/HomePage.module.scss"; 5 | import { User } from "../../../shared/types"; 6 | import * as localSettings from "../localSettings"; 7 | 8 | interface Props { 9 | user: User | undefined; 10 | } 11 | 12 | const AboutPage: FunctionalComponent = ({ user }) => { 13 | const [locale, setLocale] = useState( 14 | localSettings.locale() 15 | ); 16 | 17 | return ( 18 |
19 |

About Notepad Calculator

20 |

21 | Notepad Calculator is an open source web app that allows you to mix 22 | plain text notes with calculations. 23 |

24 | 25 |

26 | I often find myself wanting to jot down some back-of-the-envelope style 27 | calculations for which a typical calculator is too limited, and a 28 | spreadsheet is overkill. Back in 2015 I heard about an iOS app called 29 | Soulver which looked great, but since I wasn't an iOS user at the time 30 | and there was no web version available, I created my own tool here using 31 | the mathjs library for its math 32 | engine. 33 |

34 | 35 |

36 | I've used and improved it over the years, but it remains a simple tool. 37 |

38 | 39 |

40 | 41 | GitHub repo link 42 | 43 |

44 | 45 |

46 | I hope you find it useful. If you have any feedback or suggestions, 47 | please reach out to me at{" "} 48 | steveridout@gmail.com. 49 |

50 | 51 |

52 | Steve Ridout 53 |
54 | https://steveridout.com 55 |

56 | 57 |

Usage

58 | 59 |

60 | Type calculations in the left hand column and see the result in the 61 | right hand column. 62 |

63 | 64 |

Intersperse your calculations with comments and notes.

65 | 66 |

67 | Spread out long calculations over multiple lines by: 68 |

    69 |
  • 70 | Starting lines with a basic operator (+, -, *, 71 | or /) to carry on from the previous line. 72 |
  • 73 |
  • 74 | Use variables to include the result of a previous calculation. 75 |
  • 76 |
  • 77 | Use the special ans variable to refer to the previous answer. 78 |
  • 79 |
  • 80 | Use the special above variable to refer to the previous list. 81 |
  • 82 |
83 |

84 | 85 |

86 | Use functions like sqrt(), abs(), and more. (I don't have 87 | a comprehensive list of supported functions, but under the hood it uses{" "} 88 | mathjs so most of the functions listed here should work:{" "} 89 | 90 | mathjs functions 91 | 92 | .) 93 |

94 | 95 |

FAQ

96 |

Where are my notes stored?

97 |

98 | If you are not signed in, all notes are stored locally within your 99 | browser using localStorage. 100 |

101 |

102 | If you have created an account and are logged in, your notes are stored 103 | on the server. No other user may access these notes but be aware that 104 | they are stored in plain-text on my database. (If you would be 105 | interested in encrypted notes as a premium feature, please{" "} 106 | email me to let me know since 107 | I would consider adding this if there was enough interest.) 108 |

109 | 110 |

Settings

111 |

112 | 123 |

124 |

125 | 144 |
145 | 146 | (This affects the formatting of numbers in the right hand column.) 147 | 148 |

149 |
150 | ); 151 | }; 152 | 153 | export default AboutPage; 154 | -------------------------------------------------------------------------------- /docs/advancedReadme.md: -------------------------------------------------------------------------------- 1 | # Advanced Readme 2 | 3 | These notes are primarily for myself, Steve. If you just want to run notepadcalculator locally in the simplest way, see the main README.md file. 4 | 5 | # How to set up a local dev environment without Docker 6 | 7 | Prerequisites: 8 | 9 | - NodeJS 20 10 | - Postgres 12 server running 11 | 12 | ## Create DB locally 13 | 14 | ```sh 15 | createuser notepadcalculator -U postgres 16 | createdb notepadcalculator -U postgres 17 | psql -d notepadcalculator -U postgres 18 | 19 | # Within the psql prompt: 20 | GRANT ALL PRIVILEGES ON DATABASE notepadcalculator TO notepadcalculator; 21 | ``` 22 | 23 | ## Run server 24 | 25 | ```sh 26 | (cd server && npm install) 27 | (cd web && npm install) 28 | ./dev.sh 29 | ``` 30 | 31 | # Set up production environment 32 | 33 | NOTE: I wrote these notes for myself. I don't support or recommend following this except as a rough guide for someone who already knows what they are doing. 34 | 35 | ## Create DB on server 36 | 37 | ```sh 38 | ssh steve@app-1 39 | 40 | createuser notepadcalculator -h postgres-1 -U postgres 41 | createdb notepadcalculator -h postgres-1 -U postgres 42 | psql -d notepadcalculator -h postgres-1 -U postgres 43 | 44 | # Replace XXX_new_password with new password as appropriate: 45 | ALTER USER notepadcalculator WITH PASSWORD 'XXX_new_password'; 46 | 47 | # XXX this seems excessive, may not be necessary! 48 | GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO notepadcalculator; 49 | GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO notepadcalculator; 50 | 51 | # Make notepadcalculator user the owner 52 | ALTER DATABASE notepadcalculator OWNER TO notepadcalculator; 53 | 54 | # Exit psql 55 | \q 56 | 57 | psql -d notepadcalculator -h postgres-1 -U notepadcalculator 58 | ``` 59 | 60 | ## Security secrets 61 | 62 | Create a file `setProductionEnvVars.sh` which looks like this, but with appropriate values for 63 | DB_HOST and DB_PASSWORD: 64 | 65 | ```sh 66 | export DB_NAME='notepadcalculator' 67 | export DB_HOST='0.0.0.0' 68 | export DB_PASSWORD='XXXXXXXXXXX' 69 | export DB_USER='notepadcalculator' 70 | ``` 71 | 72 | ## Run initial DB migration on server 73 | 74 | ```sh 75 | # Need to do from app server due to DB firewall rules 76 | ssh steve@app-1 77 | 78 | # First time only 79 | cd ~/code 80 | git clone https://github.com/SteveRidout/notepad-calculator-3.git 81 | 82 | cd ~/code/notepadcalculator-3 83 | git pull 84 | cd server 85 | npm install 86 | npx knex migrate:latest --env production 87 | ``` 88 | 89 | ## Preparing to deploy for first time 90 | 91 | This only needs doing before the first deploy: 92 | 93 | ```sh 94 | ssh steve@app-1 95 | 96 | # Create the directory and set owner 97 | cd /var/www 98 | sudo mkdir notepadcalculator2 99 | sudo chown steve notepadcalculator2 100 | 101 | # Create empty config files and set owner 102 | sudo touch /lib/systemd/system/notepadcalculator2.service 103 | sudo chown steve /lib/systemd/system/notepadcalculator2.service 104 | 105 | sudo touch /etc/nginx/sites-enabled/notepadcalculator2.conf 106 | sudo chown steve /etc/nginx/sites-enabled/notepadcalculator2.conf 107 | ``` 108 | 109 | ## Deploy instructions 110 | 111 | ```sh 112 | ./deploy.sh 113 | 114 | # If config has changed 115 | cd config 116 | ./copyToAppServer.sh 117 | 118 | ssh steve@app-1 119 | 120 | # Run npm install 121 | cd /var/www/notepadcalculator2/server 122 | npm install 123 | 124 | sudo systemctl restart notepadcalculator2.service 125 | ``` 126 | 127 | # Migrations 128 | 129 | ## Creating DB migrations 130 | 131 | ```sh 132 | cd server 133 | npx knex migrate:make migration_name 134 | ``` 135 | 136 | ## Running DB migrations 137 | 138 | Needs to be done locally and in production 139 | 140 | ```sh 141 | cd server 142 | npm install 143 | npx knex migrate:latest 144 | ``` 145 | 146 | ## Style issues 147 | 148 | Postgres/knex: always use `text` columns instead of `string`/`varchar`. 149 | 150 | # Analytics 151 | 152 | ```sql 153 | -- Daily new users 154 | select date(creation_time), count(*) from "user" group by date(creation_time) order by date(creation_time); 155 | 156 | -- Weekly new users 157 | select date_trunc('week', creation_time) as weekly, count(*) from "user" group by weekly order by weekly; 158 | 159 | -- Weekly new notes 160 | select date_trunc('week', creation_time) as weekly, count(*) from note group by weekly order by weekly; 161 | 162 | -- Last modified notes (misleading since we lose old modifications when user modifies again) 163 | select date(last_modified_time), count(*) from note group by date(last_modified_time) order by date(last_modified_time); 164 | ``` 165 | 166 | ```sh 167 | # Check access logs 168 | sudo sh -c '(zcat /var/log/nginx/access.log.[2-5].gz; cat /var/log/nginx/access.log.1 /var/log/nginx/access.log) | grep https://notepadcalculator.com | goaccess --log-format=COMBINED --ignore-crawlers' 169 | ``` 170 | 171 | # Reset password instructions 172 | 173 | For now this involves me since we don't have user email addresses. 174 | 175 | If someone emails, here's the response: 176 | 177 | ``` 178 | Please let me know your username and the title of one or two of your notes and I can generate a password reset link for you. 179 | ``` 180 | 181 | Here's how to generate a reset link 182 | 183 | ```sh 184 | ssh steve@app-1 185 | cd code/notepad-calculator-3/server/scripts 186 | ./connectToProduction.sh 187 | 188 | # Search for their username 189 | select * from "user" where username = 'their username'; 190 | 191 | # Generate random code 192 | npx ts-node randomCode.ts 193 | 194 | # When in psql, replace values as appropriate. 195 | insert into reset_password (code, user_id) values ('xxxxxxx', $USER_ID); 196 | ``` 197 | 198 | Their reset URL will then be `https://notepadcalculator.com/resetPassword/xxxxxxx` 199 | 200 | sudo cat /var/log/nginx/access.log.1 /var/log/nginx/access.log | grep https://notepadcalculator.com | goaccess --log-format=COMBINED --ignore-crawlers 201 | -------------------------------------------------------------------------------- /server/src/routes/api.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import * as _ from "lodash"; 3 | 4 | import * as apiTypes from "../../../shared/apiTypes"; 5 | import * as userDAL from "../dal/userDAL"; 6 | import * as noteDAL from "../dal/noteDAL"; 7 | import * as resetPasswordDAL from "../dal/resetPasswordDAL"; 8 | import archiver from "archiver"; 9 | 10 | import * as auth from "../baseLib/auth"; 11 | 12 | const router = express.Router(); 13 | 14 | const isAuthenticated: express.RequestHandler = (req, res, next) => { 15 | if (req.user?.id !== undefined) { 16 | return next(); 17 | } 18 | 19 | res.status(401).send("Not authenticated"); 20 | }; 21 | 22 | // === Start User === 23 | 24 | router.post("/user", express.json(), function (req, res) { 25 | // XXX TODO create user 26 | }); 27 | 28 | router.get("/user", isAuthenticated, async (req, res) => { 29 | if (req.user === undefined) { 30 | throw Error("Not authenticated"); 31 | } 32 | 33 | const user = await userDAL.getUser({ id: req.user.id }); 34 | res.json({ 35 | user, 36 | }); 37 | }); 38 | 39 | router.post<{}, {}, apiTypes.UpdatePasswordRequest>( 40 | "/user/updatePassword", 41 | express.json(), 42 | async (req, res) => { 43 | if (req.user === undefined) { 44 | throw Error("Not authenticated"); 45 | } 46 | 47 | const user = await userDAL.getUser({ id: req.user.id }); 48 | if (!user) { 49 | res.status(404).send("User not found"); 50 | return; 51 | } 52 | 53 | if ( 54 | !(await auth.checkPassword(req.body.oldPassword, user.hashedPassword)) 55 | ) { 56 | res.status(400).send("Old password is incorrect"); 57 | return; 58 | } 59 | 60 | if (req.body.newPassword.length < 6) { 61 | res.status(400).send("New password is too short"); 62 | return; 63 | } 64 | 65 | const hashedPassword = await auth.createPasswordHash(req.body.newPassword); 66 | 67 | await userDAL.updateHashedPassword(req.user.id, hashedPassword); 68 | res.send({}); 69 | } 70 | ); 71 | 72 | // === End user === 73 | 74 | // === Start notes === 75 | 76 | router.get<{ id: string }, apiTypes.GetNoteResponse, {}, {}, {}>( 77 | "/note/:id", 78 | async (req, res) => { 79 | if (req.user === undefined) { 80 | res.status(403).send("Not authenticated" as any); 81 | return; 82 | } 83 | 84 | const noteId = parseInt(req.params.id, 10); 85 | const note = await noteDAL.getNote(req.user.id, noteId); 86 | 87 | if (note === undefined) { 88 | res.status(404).send("Note not found" as any); 89 | return; 90 | } 91 | 92 | res.json({ note }); 93 | } 94 | ); 95 | 96 | router.post< 97 | { id: string }, 98 | apiTypes.UpdateNoteResponse, 99 | apiTypes.UpdateNoteRequest, 100 | {}, 101 | {} 102 | >("/note/:id/update", express.json(), async (req, res) => { 103 | if (req.user === undefined) { 104 | res.status(403).send("Not authenticated" as any); 105 | return; 106 | } 107 | 108 | const noteId = parseInt(req.params.id, 10); 109 | const note = await noteDAL.updateNote(req.user.id, noteId, req.body.update); 110 | 111 | res.json({ note }); 112 | }); 113 | 114 | router.post< 115 | "/note", 116 | {}, 117 | apiTypes.PostNoteResponse, 118 | apiTypes.PostNoteRequest, 119 | {}, 120 | {} 121 | >("/note", express.json(), async (req, res) => { 122 | if (req.user === undefined) { 123 | res.status(403).send("Not authenticated" as any); 124 | return; 125 | } 126 | 127 | const note = await noteDAL.saveNote(req.user.id, req.body.noteData); 128 | 129 | const noteIds = await noteDAL.listNoteIds(req.user.id); 130 | await noteDAL.saveNoteIds(req.user.id, [...(noteIds ?? []), note.id]); 131 | 132 | res.json({ note }); 133 | }); 134 | 135 | router.post<{ id: string }, {}, {}, {}, {}>( 136 | "/note/:id/delete", 137 | express.json(), 138 | async (req, res) => { 139 | if (req.user === undefined) { 140 | res.status(403).send("Not authenticated" as any); 141 | return; 142 | } 143 | 144 | const noteId = parseInt(req.params.id, 10); 145 | await noteDAL.deleteNote(req.user.id, noteId); 146 | 147 | const noteIds = await noteDAL.listNoteIds(req.user.id); 148 | if (noteIds) { 149 | await noteDAL.saveNoteIds( 150 | req.user.id, 151 | noteIds.filter((thisNoteId) => thisNoteId !== noteId) 152 | ); 153 | } 154 | 155 | res.json(); 156 | } 157 | ); 158 | 159 | router.get<{ id: string }, apiTypes.ListNoteIdsResponse, {}, {}, {}>( 160 | "/noteIds", 161 | async (req, res) => { 162 | if (req.user === undefined) { 163 | res.status(403).send("Not authenticated" as any); 164 | return; 165 | } 166 | const noteIds = await noteDAL.listNoteIds(req.user.id); 167 | // if (noteIds === undefined) { 168 | // res.status(404).send("Note IDs not found" as any); 169 | // return; 170 | // } 171 | 172 | res.json({ noteIds: noteIds ?? [] }); 173 | } 174 | ); 175 | 176 | router.post<{ id: string }, {}, apiTypes.PostOrderedNoteIdsRequest, {}, {}>( 177 | "/noteIds", 178 | express.json(), 179 | async (req, res) => { 180 | if (req.user === undefined) { 181 | res.status(403).send("Not authenticated" as any); 182 | return; 183 | } 184 | 185 | await noteDAL.saveNoteIds(req.user.id, req.body.noteIds); 186 | 187 | res.json(); 188 | } 189 | ); 190 | 191 | // Exports all the users notes in a .zip file 192 | router.get("/exportNotes", async (req, res) => { 193 | if (req.user === undefined) { 194 | res.status(403).send("Not authenticated" as any); 195 | return; 196 | } 197 | 198 | const noteIds = await noteDAL.listNoteIds(req.user.id); 199 | 200 | if (noteIds === undefined) { 201 | res.status(404).send("No notes found"); 202 | return; 203 | } 204 | 205 | const archive = archiver("zip", { 206 | zlib: { level: 9 }, // Compression level 207 | }); 208 | 209 | archive.on("error", function (err) { 210 | throw err; 211 | }); 212 | 213 | // Set the archive name 214 | res.attachment("allNotes.zip"); 215 | 216 | // Pipe the zip to the response 217 | archive.pipe(res); 218 | 219 | for (const noteId of noteIds) { 220 | const note = await noteDAL.getNote(req.user.id, noteId); 221 | 222 | if (note === undefined) { 223 | continue; 224 | } 225 | 226 | // Append files to the zip 227 | archive.append(note.body, { name: `${note.title}.txt` }); 228 | } 229 | 230 | // Finalize the archive 231 | archive.finalize(); 232 | }); 233 | 234 | // === End notes === 235 | 236 | export default router; 237 | -------------------------------------------------------------------------------- /web/src/calculate.ts: -------------------------------------------------------------------------------- 1 | import * as mathjs from "mathjs"; 2 | 3 | import * as localSettings from "./localSettings"; 4 | import { Scope } from "./types"; 5 | 6 | interface OverlayText { 7 | type: "invisible" | "variable" | "comment"; 8 | text: string; 9 | } 10 | 11 | /** All the available highlight colors */ 12 | const highlightColors = [ 13 | "orange" as const, 14 | "blue" as const, 15 | "red" as const, 16 | "green" as const, 17 | ]; 18 | 19 | type HighlightColor = (typeof highlightColors)[number]; 20 | 21 | interface OverlayLine { 22 | parts: OverlayText[]; 23 | highlight?: HighlightColor; 24 | } 25 | 26 | /** 27 | * This returns a string containing all the matching variables from `input` with the appropriate 28 | * amount of whitespace in-between. 29 | */ 30 | const calcOverlayLine = function (input: string, scope: Scope): OverlayLine { 31 | let overlayParts: OverlayText[] = []; 32 | let lastIndex = 0; 33 | let match; 34 | 35 | const { preComment, comment } = (() => { 36 | const commentIndex = input.indexOf("#"); 37 | if (commentIndex === -1) { 38 | // No comment 39 | return { 40 | preComment: input, 41 | comment: "", 42 | }; 43 | } 44 | 45 | return { 46 | preComment: input.substring(0, commentIndex), 47 | comment: input.substring(commentIndex), 48 | }; 49 | })(); 50 | 51 | var regex = /[a-zA-Z_][a-zA-Z0-9_]*/g; 52 | 53 | while ((match = regex.exec(preComment)) !== null) { 54 | overlayParts.push({ 55 | type: "invisible", 56 | text: input.substring(lastIndex, match.index), 57 | }); 58 | if (match[0] in scope) { 59 | overlayParts.push({ 60 | type: "variable", 61 | text: match[0], 62 | }); 63 | } else { 64 | overlayParts.push({ 65 | type: "invisible", 66 | text: match[0], 67 | }); 68 | } 69 | lastIndex = match.index + match[0].length; 70 | } 71 | 72 | if (lastIndex < preComment.length) { 73 | overlayParts.push({ 74 | type: "invisible", 75 | text: preComment.substring(lastIndex, preComment.length), 76 | }); 77 | } 78 | 79 | if (comment.length > 0) { 80 | overlayParts.push({ 81 | type: "comment", 82 | text: comment, 83 | }); 84 | } 85 | 86 | const highlight: OverlayLine["highlight"] | undefined = (() => { 87 | const highlightRegex = new RegExp(`#.*!(${highlightColors.join("|")})$`); 88 | const highlightMatch = highlightRegex.exec(input); 89 | const highlightColor = highlightMatch 90 | ? (highlightMatch[1] as HighlightColor) 91 | : undefined; 92 | if (highlightColor && new Set(highlightColors).has(highlightColor)) { 93 | return highlightColor as OverlayLine["highlight"]; 94 | } 95 | return undefined; 96 | })(); 97 | 98 | return { 99 | parts: overlayParts, 100 | highlight, 101 | }; 102 | }; 103 | 104 | /** 105 | * Given an input string, this will calculate both the overlay and the contents of the answers 106 | * column. 107 | */ 108 | export const calculate = ( 109 | input: string 110 | // This approach will break if wrapping lines containing variables since using blank space isn't 111 | // equivalent to text when it comes to wrapping. 112 | ): { overlayLines: OverlayLine[]; answerLines: string[]; scope: Scope } => { 113 | const binaryOperators: { 114 | [operator: string]: (a: number, b: number) => number; 115 | } = { 116 | "+": function (a, b) { 117 | return a + b; 118 | }, 119 | "-": function (a, b) { 120 | return a - b; 121 | }, 122 | "/": function (a, b) { 123 | return a / b; 124 | }, 125 | "*": function (a, b) { 126 | return a * b; 127 | }, 128 | }; 129 | 130 | const lines = input.split("\n"); 131 | 132 | const answers: (number | mathjs.Unit | undefined | mathjs.Matrix)[] = []; 133 | const overlayLines: OverlayLine[] = []; 134 | 135 | /** A block is an unbroken list of consecutive answers */ 136 | const blocks: (number | mathjs.Unit)[][] = []; 137 | let withinBlock = false; 138 | 139 | let previousAnswerIndex: number | undefined; 140 | var index = -1; 141 | const scope: Scope = {}; 142 | 143 | // Calculate answers using math.eval() 144 | for (const line of lines) { 145 | index++; 146 | let overlayLine: OverlayLine = { 147 | parts: [{ type: "invisible", text: line.length === 0 ? " " : line }], 148 | }; 149 | 150 | // Populate 'aboveList' as appropriate within scope 151 | if (withinBlock && blocks.length > 1) { 152 | scope["above"] = blocks[blocks.length - 2]; 153 | } else if (blocks.length > 0) { 154 | scope["above"] = blocks[blocks.length - 1]; 155 | } 156 | 157 | if (line.length > 0) { 158 | // If the line starts with an operator (+, -, *, /), prepend the previous answer 159 | const calculation = (() => { 160 | if ( 161 | binaryOperators[line.replace(/^ +/, "")[0]] && 162 | previousAnswerIndex !== undefined && 163 | answers[previousAnswerIndex] !== undefined 164 | ) { 165 | return answers[previousAnswerIndex] + line; 166 | } 167 | return line; 168 | })(); 169 | 170 | try { 171 | const answer = mathjs.evaluate(calculation, scope); 172 | 173 | if (typeof answer === "number" || mathjs.isUnit(answer)) { 174 | answers[index] = answer; 175 | scope.ans = answer; 176 | previousAnswerIndex = index; 177 | 178 | // Add to block 179 | if (withinBlock) { 180 | blocks[blocks.length - 1].push(answer); 181 | } else { 182 | blocks.push([answer]); 183 | withinBlock = true; 184 | } 185 | } else { 186 | withinBlock = false; 187 | } 188 | 189 | if (mathjs.isArray(answer) || mathjs.isMatrix(answer)) { 190 | // Clone answer in case it's an array or other type which could be mutated 191 | answers[index] = mathjs.clone(answer) as any; 192 | scope.ans = answer; 193 | } 194 | 195 | overlayLine = calcOverlayLine(line, scope); 196 | overlayLines.push(overlayLine); 197 | } catch (error) { 198 | withinBlock = false; 199 | answers[index] = undefined; 200 | overlayLine = calcOverlayLine(line, scope); 201 | overlayLines.push(overlayLine); 202 | } 203 | } else { 204 | withinBlock = false; 205 | answers[index] = undefined; 206 | overlayLines.push(overlayLine); 207 | } 208 | } 209 | 210 | return { 211 | overlayLines, 212 | answerLines: answers.map((answer) => { 213 | if (answer === undefined) { 214 | return ""; 215 | } 216 | 217 | const localeOverride = localSettings.locale(); 218 | 219 | return mathjs.format(answer, (value) => { 220 | if ( 221 | (Math.abs(value) < 0.0001 || Math.abs(value) > 1e12) && 222 | value !== 0 223 | ) { 224 | return value.toLocaleString(localeOverride, { 225 | maximumFractionDigits: 7, 226 | notation: "scientific", 227 | }); 228 | } 229 | 230 | return value.toLocaleString(localeOverride, { 231 | maximumFractionDigits: 6, 232 | }); 233 | }); 234 | }), 235 | scope, 236 | }; 237 | }; 238 | -------------------------------------------------------------------------------- /web/src/pages/AuthPage.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h } from "preact"; 2 | 3 | import style from "../styles/AuthPage.module.scss"; 4 | import { User } from "../../../shared/types"; 5 | import * as api from "../api"; 6 | import localNoteStorage from "../localNoteStorage"; 7 | import { remoteNoteStorage } from "../api"; 8 | 9 | interface Props { 10 | user: User | undefined; 11 | type: "login" | "signUp"; 12 | } 13 | 14 | interface State { 15 | username: string; 16 | password: string; 17 | migrate: boolean; 18 | 19 | awaitingResponse: boolean; 20 | errorMessage?: string; 21 | 22 | localNotesAreDirty: boolean; 23 | } 24 | 25 | const minPasswordLength = 4; 26 | 27 | class SignUpPage extends Component { 28 | constructor(props: Props) { 29 | super(props); 30 | 31 | this.state = { 32 | username: "", 33 | password: "", 34 | migrate: true, 35 | 36 | awaitingResponse: false, 37 | 38 | localNotesAreDirty: false, 39 | }; 40 | 41 | this.setIsDirty(); 42 | } 43 | 44 | async setIsDirty() { 45 | this.setState({ 46 | ...this.state, 47 | localNotesAreDirty: await localNoteStorage.isDirty?.(), 48 | }); 49 | } 50 | 51 | async migrateLocalNotesToRemote() { 52 | const noteIds = await localNoteStorage.listNoteIds(); 53 | 54 | for (const noteId of noteIds) { 55 | const note = await localNoteStorage.getNote(noteId); 56 | if (!note) { 57 | continue; 58 | } 59 | 60 | // Save to server 61 | await remoteNoteStorage.saveNote({ title: note.title, body: note.body }); 62 | } 63 | } 64 | 65 | async signUp() { 66 | const { username, password } = this.state; 67 | 68 | this.setState({ ...this.state, awaitingResponse: true }); 69 | const response = await api.signUp(username, password); 70 | 71 | switch (response.type) { 72 | case "user-created": 73 | await this.migrateLocalNotesToRemote(); 74 | await localNoteStorage.clear?.(); 75 | this.loggedIn(); 76 | break; 77 | 78 | case "user-already-exists": 79 | this.setState({ 80 | ...this.state, 81 | awaitingResponse: false, 82 | errorMessage: "This username already exists", 83 | }); 84 | break; 85 | } 86 | } 87 | 88 | async login() { 89 | console.log("login"); 90 | const { username, password } = this.state; 91 | 92 | this.setState({ ...this.state, awaitingResponse: true }); 93 | const success = await api.login(username, password); 94 | 95 | if (success) { 96 | this.loggedIn(); 97 | } else { 98 | this.setState({ 99 | ...this.state, 100 | awaitingResponse: false, 101 | errorMessage: "Username or password not recognized", 102 | }); 103 | } 104 | } 105 | 106 | async loggedIn() { 107 | // Copy across notes to server if required 108 | if (this.state.localNotesAreDirty && this.state.migrate) { 109 | await this.migrateLocalNotesToRemote(); 110 | } 111 | 112 | // Delete any locally stored notes 113 | await localNoteStorage.clear?.(); 114 | 115 | window.location.href = "/"; 116 | } 117 | 118 | isValid(): boolean { 119 | const { username, password } = this.state; 120 | 121 | switch (this.props.type) { 122 | case "signUp": 123 | return username.length >= 1 && password.length >= minPasswordLength; 124 | 125 | case "login": 126 | return username.length >= 1 && password.length >= minPasswordLength; 127 | } 128 | } 129 | 130 | render() { 131 | const { type } = this.props; 132 | const { 133 | username, 134 | password, 135 | migrate, 136 | errorMessage, 137 | localNotesAreDirty, 138 | awaitingResponse, 139 | } = this.state; 140 | 141 | // console.log("local note dirty:", localNotesDirty); 142 | 143 | return ( 144 |
145 |

{type === "signUp" ? "Sign Up" : "Login"}

146 | {type === "signUp" ? ( 147 |

148 | Sign up for free cloud syncing of your notes and edit them on any 149 | device! 150 |

151 | ) : null} 152 | 153 |
154 | 155 | 156 | 159 | 174 | 175 | 176 | 179 | 194 | 195 | {localNotesAreDirty ? ( 196 | 197 | 202 | 216 | 217 | ) : null} 218 |
157 | 158 | 160 | { 165 | this.setState({ 166 | ...this.state, 167 | username: event.currentTarget.value, 168 | errorMessage: undefined, 169 | }); 170 | }} 171 | value={username} 172 | /> 173 |
177 | 178 | 180 | { 185 | this.setState({ 186 | ...this.state, 187 | password: event.currentTarget.value, 188 | errorMessage: undefined, 189 | }); 190 | }} 191 | value={password} 192 | /> 193 |
198 | 201 | 203 | { 207 | console.log("checkbox input: ", event); 208 | this.setState({ 209 | ...this.state, 210 | migrate: event.currentTarget.checked, 211 | }); 212 | }} 213 | checked={migrate} 214 | /> 215 |
219 | {localNotesAreDirty && !migrate ? ( 220 |

221 | WARNING: your current notes will be lost upon{" "} 222 | {type === "signUp" ? "sign-up" : "login"}! 223 |

224 | ) : null} 225 |

226 | {type === "signUp" ? ( 227 | 233 | ) : ( 234 | 240 | )} 241 |

242 | {errorMessage ? ( 243 |

{errorMessage}

244 | ) : null} 245 | {type === "login" ? ( 246 |

247 | 248 | Forgot password? Email me at{" "} 249 | steveridout@gmail.com 250 | . 251 | 252 |

253 | ) : null} 254 |
255 |
256 | ); 257 | } 258 | } 259 | 260 | export default SignUpPage; 261 | -------------------------------------------------------------------------------- /web/src/styles/CalculationsColumn.module.scss: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;700&display=swap"); 2 | @import "./constants.scss"; 3 | 4 | .calculations-column { 5 | display: flex; 6 | flex-direction: column; 7 | flex: 2 1 auto; 8 | } 9 | 10 | .delete-page-button { 11 | display: inline-block; 12 | text-align: right; 13 | margin: $unit 0; 14 | font-weight: 700; 15 | padding-left: $unit; 16 | } 17 | 18 | .note-header-row { 19 | display: flex; 20 | flex-direction: row; 21 | align-items: center; 22 | } 23 | 24 | .note-name-heading { 25 | font-size: 27px; 26 | font-weight: 700; 27 | margin: $unit 2px; 28 | border: none; 29 | padding: $unit; 30 | 31 | @media (max-width: $max-small-desktop-width) { 32 | font-size: 24px; 33 | } 34 | 35 | @media (max-width: $max-mobile-width) { 36 | font-size: 19px; 37 | } 38 | } 39 | 40 | .note-name-container { 41 | flex: 1 1 auto; 42 | position: relative; 43 | } 44 | 45 | .note-name-dropdown-triangle-container { 46 | position: absolute; 47 | top: 0; 48 | right: 0; 49 | text-align: right; 50 | bottom: 0; 51 | display: flex; 52 | justify-content: center; 53 | align-items: center; 54 | } 55 | 56 | .note-name-dropdown-triangle { 57 | padding: $unit; 58 | cursor: pointer; 59 | } 60 | 61 | .dropdown { 62 | position: absolute; 63 | top: calc(100% - $unit - 1px); 64 | left: 0; 65 | right: 0; 66 | background: #fff; 67 | border: 1px solid #000; 68 | border-radius: 3px; 69 | padding: 0 $unit $unit; 70 | z-index: 1; 71 | max-height: 65vh; 72 | overflow-y: scroll; 73 | 74 | // XXX margin to match the input and textarea, can't remember why these were necessary 75 | margin: 0 2px; 76 | 77 | &.dark-mode { 78 | background-color: $color-background-dark; 79 | border-color: #fff; 80 | } 81 | } 82 | 83 | .note-name-heading { 84 | border: 1px solid #000; 85 | border-radius: 3px; 86 | width: calc(100% - 4px); 87 | 88 | &:focus { 89 | outline: none; 90 | border-color: $color-cta; 91 | box-shadow: 0 0 0 1px $color-cta; 92 | } 93 | 94 | &.dark-mode { 95 | background-color: $color-background-dark; 96 | border-color: #fff; 97 | color: $color-text-dark; 98 | 99 | &:focus { 100 | border-color: $color-cta-dark; 101 | box-shadow: 0 0 0 1px $color-cta-dark; 102 | box-shadow: 0 0 0 1px $color-cta-dark; 103 | } 104 | } 105 | } 106 | 107 | @mixin note-text { 108 | font-size: 16px; 109 | font-family: Menlo, "Roboto Mono", monospace; 110 | white-space: pre-wrap; 111 | word-break: normal; 112 | overflow-wrap: anywhere; 113 | margin: 0; 114 | padding: $unit 33% $unit $unit; 115 | border: 0; 116 | line-height: 1.4; 117 | 118 | @media (max-width: $max-small-desktop-width) { 119 | font-size: 14px; 120 | } 121 | 122 | @media (max-width: $max-mobile-width) { 123 | font-size: 12px; 124 | } 125 | } 126 | 127 | @mixin underline { 128 | &::after { 129 | content: ""; 130 | position: absolute; 131 | bottom: 1px; 132 | height: 1px; 133 | left: 0; 134 | right: 0; 135 | background-color: $color-ruler; 136 | } 137 | } 138 | 139 | .autocomplete-container { 140 | @include note-text; 141 | position: absolute; 142 | inset: 0; 143 | pointer-events: none; 144 | color: transparent; 145 | } 146 | 147 | .selected-word { 148 | white-space: nowrap; 149 | } 150 | 151 | .autocomplete-suggestions-container { 152 | position: relative; 153 | display: inline-block; 154 | font-weight: 700; 155 | } 156 | 157 | .autocomplete-suggestions { 158 | position: absolute; 159 | top: 100%; 160 | left: 0; 161 | margin-top: $unit; 162 | background: #fff; 163 | border: 1px solid #888; 164 | 165 | ul { 166 | padding: 0; 167 | margin: 0; 168 | list-style: none; 169 | } 170 | 171 | .suggestion { 172 | padding: 2px $unit; 173 | color: #888; 174 | pointer-events: auto; 175 | cursor: pointer; 176 | 177 | &.selected-suggestion { 178 | background-color: $color-cta; 179 | color: #fff; 180 | } 181 | } 182 | 183 | .suggestion-type { 184 | font-weight: 500; 185 | opacity: 0.6; 186 | } 187 | 188 | &.dark-mode { 189 | background: #000; 190 | border-color: rgb(82, 90, 110); 191 | 192 | .suggestion { 193 | color: #888; 194 | 195 | &.selected-suggestion { 196 | background-color: darken($color-cta-dark, 30%); 197 | color: $color-text-dark; 198 | } 199 | } 200 | } 201 | } 202 | 203 | .calculations-container { 204 | position: relative; 205 | flex: 1 1 auto; 206 | overflow-y: scroll; 207 | border: 1px solid #000; 208 | border-radius: 3px; 209 | margin: 2px; 210 | 211 | &.calculations-container-focussed { 212 | border-color: $color-cta; 213 | box-shadow: 0 0 0 1px $color-cta; 214 | } 215 | 216 | .calculations-inner-container { 217 | position: relative; 218 | 219 | // This ensures that the text area takes up most of the vertical space even when it doesn't 220 | // contain much text, so that if the user clicks in the empty white space below the current 221 | // contents, it will still focus the text area. 222 | min-height: calc(100% - 10px); 223 | 224 | padding-bottom: 40px; 225 | } 226 | 227 | textarea { 228 | @include note-text; 229 | position: absolute; 230 | inset: 0; 231 | resize: none; 232 | overflow: hidden; 233 | height: 100%; 234 | width: 100%; 235 | 236 | &:focus { 237 | outline: none; 238 | } 239 | } 240 | 241 | .overlay { 242 | @include note-text; 243 | position: relative; 244 | pointer-events: none; 245 | color: transparent; 246 | font-weight: 700; 247 | margin-bottom: $unit; 248 | padding-left: 0; 249 | } 250 | 251 | .overlay-line { 252 | position: relative; 253 | padding-left: $unit; 254 | 255 | &.underline { 256 | @include underline; 257 | 258 | &.dark-mode { 259 | &::after { 260 | background-color: $color-ruler-dark; 261 | } 262 | } 263 | } 264 | } 265 | 266 | .highlight-orange { 267 | background-color: rgba(255, 208, 36, 0.749); 268 | 269 | &.dark-mode { 270 | background-color: rgba(255, 208, 36, 0.329); 271 | } 272 | } 273 | 274 | .highlight-blue { 275 | background-color: rgba(34, 226, 255, 0.743); 276 | 277 | &.dark-mode { 278 | background-color: rgba(34, 226, 255, 0.391); 279 | } 280 | } 281 | 282 | .highlight-red { 283 | background-color: rgba(255, 22, 22, 0.802); 284 | 285 | &.dark-mode { 286 | background-color: rgba(255, 22, 22, 0.27); 287 | } 288 | } 289 | 290 | .highlight-green { 291 | background-color: rgba(37, 255, 22, 0.802); 292 | 293 | &.dark-mode { 294 | background-color: rgba(37, 255, 22, 0.316); 295 | } 296 | } 297 | 298 | .highlight-green, 299 | .highlight-orange, 300 | .highlight-blue, 301 | .highlight-red { 302 | .overlay-part-hidden { 303 | color: #000; 304 | font-weight: 500; 305 | } 306 | 307 | &.dark-mode { 308 | .overlay-part-hidden { 309 | color: #fff; 310 | } 311 | } 312 | } 313 | 314 | .vertical-rule { 315 | position: absolute; 316 | inset: 0 calc(33% - $unit) 0 0; 317 | border-right: 1px solid $color-ruler; 318 | pointer-events: none; 319 | } 320 | 321 | .overlay-part-visible { 322 | color: $color-cta; 323 | } 324 | 325 | .overlay-part-hidden { 326 | color: transparent; 327 | } 328 | 329 | .overlay-part-comment { 330 | color: $color-comment; 331 | font-weight: 500; 332 | } 333 | 334 | .overlay-part-comment-highlight { 335 | color: rgba(0, 0, 0, 0.3); 336 | font-weight: 500; 337 | 338 | .highlight-orange { 339 | color: rgb(190, 149, 0); 340 | 341 | &.dark-mode { 342 | color: rgb(255, 225, 118); 343 | } 344 | } 345 | 346 | .highlight-blue { 347 | color: rgb(0, 156, 180); 348 | 349 | &.dark-mode { 350 | color: rgb(84, 232, 255); 351 | } 352 | } 353 | 354 | .highlight-red { 355 | color: rgb(186, 0, 0); 356 | 357 | &.dark-mode { 358 | color: rgb(255, 97, 97); 359 | } 360 | } 361 | 362 | .highlight-green { 363 | color: rgb(11, 169, 0); 364 | 365 | &.dark-mode { 366 | color: rgb(106, 255, 95); 367 | } 368 | } 369 | } 370 | 371 | .overlay-answer { 372 | position: absolute; 373 | bottom: 0; 374 | left: 100%; 375 | padding-left: 2 * $unit; 376 | color: $color-answer; 377 | pointer-events: auto; 378 | white-space: nowrap; 379 | 380 | @include underline; 381 | 382 | &.answer-changed { 383 | color: $color-answer-changed; 384 | } 385 | 386 | &.highlight-blue { 387 | color: rgb(0, 0, 92); 388 | } 389 | 390 | &.highlight-orange { 391 | color: rgb(94, 94, 0); 392 | } 393 | 394 | &.highlight-red { 395 | color: rgb(103, 0, 0); 396 | } 397 | 398 | &.highlight-green { 399 | color: rgb(3, 82, 0); 400 | } 401 | 402 | &.highlight-blue, 403 | &.highlight-orange, 404 | &.highlight-red, 405 | &.highlight-green { 406 | padding-right: $unit; 407 | 408 | &::after { 409 | display: none; 410 | } 411 | } 412 | } 413 | 414 | &.dark-mode { 415 | border-color: #fff; 416 | 417 | &.calculations-container-focussed { 418 | border-color: $color-cta-dark; 419 | box-shadow: 0 0 0 1px $color-cta-dark; 420 | } 421 | 422 | textarea { 423 | background-color: $color-background-dark; 424 | color: $color-text-dark; 425 | } 426 | 427 | .overlay-part-visible { 428 | color: $color-cta-dark; 429 | } 430 | 431 | .overlay-part-comment { 432 | color: $color-comment-dark; 433 | } 434 | 435 | .overlay-answer { 436 | color: $color-answer-dark; 437 | 438 | &.answer-changed { 439 | color: $color-answer-changed-dark; 440 | } 441 | 442 | &.highlight-blue, 443 | &.highlight-orange, 444 | &.highlight-red, 445 | &.highlight-green { 446 | color: #fff; 447 | } 448 | } 449 | 450 | .vertical-rule { 451 | border-right-color: $color-ruler-dark; 452 | } 453 | 454 | .overlay-line.underline.dark-mode, 455 | .overlay-answer { 456 | &::after { 457 | background-color: $color-ruler-dark; 458 | } 459 | } 460 | } 461 | } 462 | 463 | .answers-column { 464 | flex: 1 0 auto; 465 | max-width: 25%; 466 | 467 | .answers-container { 468 | overflow: hidden; 469 | } 470 | 471 | .answers { 472 | position: relative; 473 | @include note-text; 474 | } 475 | } 476 | -------------------------------------------------------------------------------- /web/src/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { Component, h } from "preact"; 2 | 3 | import { Note, User } from "../../../shared/types"; 4 | import style from "../styles/HomePage.module.scss"; 5 | import localNoteStorage from "../localNoteStorage"; 6 | import { remoteNoteStorage } from "../api"; 7 | import { NoteStorageAPI } from "../types"; 8 | import * as utils from "../utils"; 9 | import * as config from "../config"; 10 | import CalculationsColumn from "../components/CalculationsColumn"; 11 | import NoteList from "../components/NoteList"; 12 | import * as legacyLocalStorage from "../legacyLocalStorage"; 13 | import * as calculate from "../calculate"; 14 | 15 | interface Props { 16 | user: User | undefined; 17 | noteId?: string; 18 | isMobile: boolean; 19 | } 20 | 21 | type State = 22 | | { 23 | type: "loading"; 24 | } 25 | | { 26 | type: "loaded"; 27 | 28 | autoFocus: "title" | "body" | undefined; 29 | 30 | notes: { [id: number]: Note }; 31 | 32 | // This is the order the notes will appear in the app 33 | noteIds: number[]; 34 | 35 | selectedNoteId: number; 36 | 37 | textareaFocussed: boolean; 38 | 39 | selectedNoteCalculationResult: ReturnType; 40 | }; 41 | 42 | const welcomeNoteData: { title: string; body: string }[] = [config.exampleNote]; 43 | 44 | class HomePage extends Component { 45 | noteStorage: NoteStorageAPI; 46 | 47 | debouncer = new utils.MultiDebouncer(400); 48 | 49 | constructor(props: Props) { 50 | super(props); 51 | this.state = { type: "loading" }; 52 | 53 | if (props.user === undefined) { 54 | this.noteStorage = localNoteStorage; 55 | } else { 56 | this.noteStorage = remoteNoteStorage; 57 | } 58 | 59 | this.load(); 60 | } 61 | 62 | async load() { 63 | let noteIds = await this.noteStorage.listNoteIds(); 64 | 65 | const noteId = this.props.noteId 66 | ? parseInt(this.props.noteId, 10) 67 | : undefined; 68 | 69 | if (noteIds.length === 0) { 70 | console.log("setting default example state"); 71 | const notes: { [id: number]: Note } = {}; 72 | const noteIds: number[] = []; 73 | 74 | const legacyNote = legacyLocalStorage.getValue(); 75 | 76 | const initialNoteData: typeof welcomeNoteData = legacyNote 77 | ? [ 78 | { 79 | title: "My Note", 80 | body: legacyNote, 81 | }, 82 | ] 83 | : welcomeNoteData; 84 | 85 | // Save example notes 86 | for (const { title, body } of initialNoteData) { 87 | console.log("saving note: ", title); 88 | const note = await this.noteStorage.saveNote({ 89 | title, 90 | body, 91 | }); 92 | console.log("saved note: ", title); 93 | notes[note.id] = note; 94 | noteIds.push(note.id); 95 | } 96 | 97 | if (!legacyNote) { 98 | // Set notes to not-dirty if they are just the default welcome message 99 | await this.noteStorage.setDirty?.(false); 100 | } 101 | 102 | const selectedNoteId = 103 | noteId !== undefined && notes[noteId] !== undefined 104 | ? noteId 105 | : noteIds[0]; 106 | 107 | this.setState({ 108 | type: "loaded", 109 | notes: notes, 110 | noteIds: noteIds, 111 | selectedNoteId, 112 | autoFocus: undefined, 113 | textareaFocussed: false, 114 | selectedNoteCalculationResult: calculate.calculate( 115 | notes[selectedNoteId].body 116 | ), 117 | }); 118 | } else { 119 | const selectedNoteName = noteIds[0]; 120 | 121 | if (selectedNoteName === undefined) { 122 | throw Error("Unexpected state"); 123 | } 124 | 125 | // Fetch all notes 126 | const notes: { [noteId: number]: Note } = {}; 127 | 128 | const fetchedNotes = await Promise.all( 129 | noteIds.map(async (noteId) => { 130 | try { 131 | return await this.noteStorage.getNote(noteId); 132 | } catch (error) { 133 | console.error("failed to fetch note: ", noteId, " skipping"); 134 | 135 | // Remove from noteIds 136 | noteIds = noteIds.filter((thisNoteId) => thisNoteId !== noteId); 137 | 138 | return undefined; 139 | } 140 | }) 141 | ); 142 | 143 | for (const note of fetchedNotes) { 144 | if (note) { 145 | notes[note.id] = note; 146 | } else { 147 | continue; 148 | } 149 | } 150 | 151 | const selectedNoteId = 152 | noteId !== undefined && notes[noteId] !== undefined 153 | ? noteId 154 | : noteIds[0]; 155 | const selectedNote = notes[selectedNoteId]; 156 | 157 | this.setState({ 158 | type: "loaded", 159 | notes, 160 | noteIds, 161 | selectedNoteId, 162 | autoFocus: selectedNote.title === "" ? "title" : "body", 163 | textareaFocussed: false, 164 | selectedNoteCalculationResult: calculate.calculate( 165 | notes[selectedNoteId].body 166 | ), 167 | }); 168 | } 169 | } 170 | 171 | initSelectedNote() { 172 | if (this.state.type !== "loaded") { 173 | throw Error("Unexpected state"); 174 | } 175 | 176 | const { selectedNoteId, notes } = this.state; 177 | 178 | if (!this.props.noteId) { 179 | // Set the pathname?? 180 | return; 181 | } 182 | 183 | const id = parseInt(this.props.noteId, 10); 184 | if (id !== selectedNoteId && notes[id]) { 185 | this.setState({ ...this.state, selectedNoteId: id }); 186 | } 187 | } 188 | 189 | renameSelectedNote(newTitle: string) { 190 | if (this.state.type !== "loaded") { 191 | throw Error("Unexpected state"); 192 | } 193 | 194 | const { notes, selectedNoteId } = this.state; 195 | 196 | // Change the selected note name 197 | this.setState({ 198 | ...this.state, 199 | notes: { 200 | ...notes, 201 | [selectedNoteId]: { 202 | ...notes[selectedNoteId], 203 | title: newTitle, 204 | }, 205 | }, 206 | }); 207 | 208 | this.saveUpdatedNote(selectedNoteId, { title: newTitle }); 209 | } 210 | 211 | async addNewNote() { 212 | const newNoteTitle = ""; 213 | 214 | if (this.state.type !== "loaded") { 215 | throw Error("Unexpected state"); 216 | } 217 | 218 | const { notes, noteIds } = this.state; 219 | 220 | const body = ""; 221 | 222 | const note = await this.noteStorage.saveNote({ 223 | title: newNoteTitle, 224 | body, 225 | }); 226 | 227 | this.setState({ 228 | ...this.state, 229 | notes: { ...notes, [note.id]: note }, 230 | noteIds: [...noteIds, note.id], 231 | selectedNoteId: note.id, 232 | selectedNoteCalculationResult: calculate.calculate(body), 233 | autoFocus: "title", 234 | }); 235 | 236 | // XXX Would be nice to auto-highlight the note name editor now, and then after that to 237 | // auto highlight the note body editor 238 | } 239 | 240 | deleteNote() { 241 | if (!confirm("Are you sure you want to permanently delete this note?")) { 242 | return; 243 | } 244 | 245 | if (this.state.type !== "loaded") { 246 | throw Error("Unexpected state"); 247 | } 248 | 249 | const { notes, noteIds, selectedNoteId } = this.state; 250 | 251 | const newNotes = { ...notes }; 252 | delete newNotes[selectedNoteId]; 253 | 254 | const previousIndex = noteIds.indexOf(selectedNoteId); 255 | 256 | const newSelectedNoteId = noteIds[Math.max(0, previousIndex - 1)]; 257 | 258 | this.setState({ 259 | ...this.state, 260 | notes: newNotes, 261 | noteIds: noteIds.filter((id) => id !== selectedNoteId), 262 | selectedNoteId: newSelectedNoteId, 263 | selectedNoteCalculationResult: 264 | noteIds.length > 0 265 | ? calculate.calculate(this.state.notes[newSelectedNoteId].body) 266 | : undefined, 267 | }); 268 | 269 | this.noteStorage.deleteNote(selectedNoteId); 270 | } 271 | 272 | saveUpdatedNote(noteId: number, update: { title?: string; body?: string }) { 273 | this.debouncer.call(() => { 274 | this.noteStorage.updateNote(noteId, update); 275 | }, [noteId, update.title !== undefined, update.body !== undefined].join("-")); 276 | } 277 | 278 | editSelectedNoteBody = (body: string, callback?: () => void) => { 279 | if (this.state.type !== "loaded") { 280 | throw Error("Unexpected state"); 281 | } 282 | 283 | const { notes, selectedNoteId } = this.state; 284 | 285 | this.setState( 286 | { 287 | notes: { 288 | ...this.state.notes, 289 | [this.state.selectedNoteId]: { 290 | ...notes[selectedNoteId], 291 | body, 292 | }, 293 | }, 294 | selectedNoteCalculationResult: calculate.calculate(body), 295 | }, 296 | callback 297 | ); 298 | 299 | this.saveUpdatedNote(selectedNoteId, { body }); 300 | }; 301 | 302 | selectNote(noteId: number) { 303 | if (this.state.type !== "loaded") { 304 | throw Error("Unexpected state: Not loaded"); 305 | } 306 | 307 | this.setState({ 308 | ...this.state, 309 | selectedNoteId: noteId, 310 | selectedNoteCalculationResult: calculate.calculate( 311 | this.state.notes[noteId].body 312 | ), 313 | }); 314 | } 315 | 316 | render() { 317 | if (this.state.type === "loading") { 318 | return
Loading...
; 319 | } 320 | 321 | const isMobile = this.props.isMobile; 322 | const { notes, noteIds, selectedNoteId, selectedNoteCalculationResult } = 323 | this.state; 324 | 325 | const note = notes[selectedNoteId]; 326 | 327 | return ( 328 |
329 | {isMobile ? null : ( 330 |
331 | this.selectNote(noteId)} 336 | addNote={() => this.addNewNote()} 337 | /> 338 |
339 | )} 340 | {note ? ( 341 | this.renameSelectedNote(title)} 347 | deleteNote={() => this.deleteNote()} 348 | editSelectedNoteBody={this.editSelectedNoteBody} 349 | setTextareaFocussed={(focussed) => 350 | this.setState({ ...this.state, textareaFocussed: focussed }) 351 | } 352 | propsForNoteList={{ 353 | noteIds: noteIds, 354 | selectedNoteId: selectedNoteId, 355 | notes: notes, 356 | selectNote: (noteId) => this.selectNote(noteId), 357 | addNote: () => this.addNewNote(), 358 | }} 359 | isMobile={isMobile} 360 | calculationResult={selectedNoteCalculationResult} 361 | /> 362 | ) : ( 363 |
364 | {isMobile ? ( 365 | this.addNewNote()}>Click to add a New Note 366 | ) : ( 367 | 368 | ← Click on "+ New Note" to add a new page of 369 | notes 370 | 371 | )} 372 |
373 | )} 374 |
375 | ); 376 | } 377 | 378 | changeSelectedNote(delta: number) { 379 | if (this.state.type !== "loaded") { 380 | throw Error("Unexpected state"); 381 | } 382 | 383 | const { noteIds, selectedNoteId } = this.state; 384 | const selectedNoteIndex = noteIds.indexOf(selectedNoteId); 385 | const newIndex = Math.min( 386 | noteIds.length - 1, 387 | Math.max(0, selectedNoteIndex + delta) 388 | ); 389 | 390 | this.setState({ 391 | ...this.state, 392 | selectedNoteId: noteIds[newIndex], 393 | // XXX This doesn't seem to be working :-( 394 | // Maybe something to do with being within a keyboard interaction?? 395 | autoFocus: "body", 396 | selectedNoteCalculationResult: calculate.calculate( 397 | this.state.notes[newIndex].body 398 | ), 399 | }); 400 | } 401 | 402 | onKeyDown = (event: KeyboardEvent) => { 403 | if (event.key === "ArrowUp" && event.altKey) { 404 | this.changeSelectedNote(-1); 405 | } 406 | if (event.key === "ArrowDown" && event.altKey) { 407 | this.changeSelectedNote(+1); 408 | } 409 | }; 410 | 411 | componentDidMount(): void { 412 | // Set up keyboard handler 413 | document.addEventListener("keydown", this.onKeyDown); 414 | } 415 | 416 | componentWillUnmount(): void { 417 | document.removeEventListener("keydown", this.onKeyDown); 418 | } 419 | 420 | componentDidUpdate(): void { 421 | if (this.state.type !== "loaded") { 422 | return; 423 | } 424 | 425 | if (this.props.user === undefined) { 426 | // Don't set path when in local mode 427 | return; 428 | } 429 | 430 | // XXX Feels like breaking abstraction layer to do this here 431 | const currentPathNoteId = (() => { 432 | const splitPath = location.pathname.split("/"); 433 | if (splitPath[1] === "notes" && splitPath[2]) { 434 | return parseInt(splitPath[2], 10); 435 | } 436 | })(); 437 | 438 | if (currentPathNoteId !== this.state.selectedNoteId) { 439 | if (this.state.selectedNoteId === undefined) { 440 | history.pushState({}, "", "/"); 441 | } else { 442 | history.pushState({}, "", `/notes/${this.state.selectedNoteId}`); 443 | } 444 | } 445 | } 446 | } 447 | 448 | export default HomePage; 449 | -------------------------------------------------------------------------------- /web/src/components/CalculationsColumn.tsx: -------------------------------------------------------------------------------- 1 | import { createRef, Component, h } from "preact"; 2 | import * as _ from "lodash"; 3 | import * as mathjs from "mathjs"; 4 | 5 | import * as calculate from "../calculate"; 6 | import { Note } from "../../../shared/types"; 7 | import style from "../styles/CalculationsColumn.module.scss"; 8 | import { Props as NoteListProps } from "./NoteList"; 9 | import NoteList from "./NoteList"; 10 | import * as localSettings from "../localSettings"; 11 | import { MathValue } from "../types"; 12 | 13 | interface Props { 14 | note: Note; 15 | textareaFocussed: boolean; 16 | autoFocus: "title" | "body" | undefined; 17 | isMobile: boolean; 18 | calculationResult: ReturnType; 19 | 20 | renameSelectedNote: (title: string) => void; 21 | deleteNote: () => void; 22 | editSelectedNoteBody: (body: string, callback?: () => void) => void; 23 | setTextareaFocussed: (focussed: boolean) => void; 24 | 25 | // XXX This is so hacky!! Think about using redux or similar global state instead 26 | propsForNoteList: NoteListProps; 27 | } 28 | 29 | interface Suggestion { 30 | type: string; 31 | suggestion: string; 32 | } 33 | 34 | interface State { 35 | dropdownVisible: boolean; 36 | 37 | caretPosition?: { 38 | x: number; 39 | y: number; 40 | }; 41 | wordAtCaretPrefix?: string; 42 | 43 | suggestions?: { 44 | list: Suggestion[]; 45 | selectedIndex: number; 46 | }; 47 | 48 | lastInteractionWasADeletion: boolean; 49 | } 50 | 51 | // XXX could be a FunctionComponent 52 | class CalculationsColumn extends Component { 53 | textareaRef = createRef(); 54 | 55 | constructor(props: Props) { 56 | super(props); 57 | this.state = { dropdownVisible: false, lastInteractionWasADeletion: false }; 58 | } 59 | 60 | renderList() {} 61 | 62 | previousAnswerLines: string[] = []; 63 | 64 | suggestionsVisible() { 65 | const { caretPosition, wordAtCaretPrefix, suggestions } = this.state; 66 | return caretPosition !== undefined && wordAtCaretPrefix && suggestions; 67 | } 68 | 69 | elideText(text: string, maxLength: number = 16) { 70 | if (text.length > maxLength) { 71 | return {text.substring(0, maxLength - 3)}...; 72 | } 73 | return text; 74 | } 75 | 76 | render() { 77 | const { 78 | note, 79 | textareaFocussed, 80 | autoFocus, 81 | isMobile, 82 | calculationResult: { overlayLines, answerLines, scope }, 83 | renameSelectedNote, 84 | deleteNote, 85 | editSelectedNoteBody, 86 | setTextareaFocussed, 87 | propsForNoteList, 88 | } = this.props; 89 | 90 | const { caretPosition, wordAtCaretPrefix, suggestions } = this.state; 91 | const bodyLines = note.body.split("\n"); 92 | 93 | const selectedWord: { start: number; end: number } | undefined = (() => { 94 | if (caretPosition && wordAtCaretPrefix) { 95 | // Figure out whole word around caretPosition, this is necessary to ensure that we don't 96 | // break the word up onto multiple lines when doing text reflow (wrapping) 97 | const caretLine = bodyLines[caretPosition.y]; 98 | 99 | if (caretLine[caretPosition.x - 1] === " ") { 100 | // Not within a word 101 | return undefined; 102 | } 103 | 104 | // Add preceding characters 105 | let start = caretPosition.x; 106 | while (start - 1 >= 0 && caretLine[start - 1] !== " ") { 107 | start -= 1; 108 | } 109 | 110 | // Add subsequent characters 111 | let end = caretPosition.x; 112 | while (end + 1 < caretLine.length && caretLine[end + 1] !== " ") { 113 | end += 1; 114 | } 115 | 116 | return { start, end }; 117 | } 118 | })(); 119 | 120 | const darkModeClass = localSettings.darkMode() ? style["dark-mode"] : ""; 121 | 122 | const result = ( 123 |
124 |
125 |
126 | { 133 | const value = event.currentTarget.value; 134 | renameSelectedNote(value); 135 | }} 136 | /> 137 | {isMobile ? ( 138 |
139 |
142 | this.setState({ 143 | dropdownVisible: !this.state.dropdownVisible, 144 | }) 145 | } 146 | > 147 | ▼ 148 |
149 |
150 | ) : null} 151 | {isMobile && this.state.dropdownVisible ? ( 152 |
158 | 159 |
160 | ) : null} 161 |
162 | { 165 | deleteNote(); 166 | }} 167 | > 168 | Delete 169 | 170 |
171 |
178 |
179 |