├── .babelrc ├── .editorconfig ├── .eslintrc ├── .github └── CODEOWNERS ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── add.svg ├── edit.svg ├── pin.svg └── trash.svg ├── icon.svg ├── package.json ├── src ├── Note.jsx ├── Notes.jsx ├── main.ts └── renderer.jsx ├── style.css ├── tsconfig.json └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { 4 | "targets": { 5 | "electron": "3.0" 6 | } 7 | }], 8 | "@babel/preset-react" 9 | ], 10 | "plugins": [ 11 | [ 12 | "@babel/plugin-proposal-class-properties" 13 | ] 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = tab 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_size = 4 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "@getflywheel/eslint-config-local" 5 | ], 6 | "rules": { 7 | "import/no-unresolved": [ 8 | 2, 9 | { 10 | "ignore": [ 11 | "^local" 12 | ] 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # These owners will be the default owners for everything in 2 | # the repo. Unless a later match takes precedence, 3 | # @getflywheel/local-engineers will be requested for 4 | # review when someone opens a pull request. 5 | * @getflywheel/local-engineers 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Intellij 9 | .idea 10 | 11 | # Output/build 12 | lib 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # TypeScript v1 declaration files 46 | typings/ 47 | 48 | # Optional npm cache directory 49 | .npm 50 | 51 | # Optional eslint cache 52 | .eslintcache 53 | 54 | # Optional REPL history 55 | .node_repl_history 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | 66 | # next.js build output 67 | .next 68 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Flywheel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Local Add-on Notes • [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/getflywheel/local-addon-volumes/pulls/) 2 | 3 | ## Installation 4 | 5 | ### Clone 6 | 7 | Clone the repository into the following directory depending on your platform: 8 | 9 | - macOS: `~/Library/Application Support/Local/addons` 10 | 11 | ### Install Dependencies 12 | 1. `yarn install` 13 | 14 | ## Development 15 | 16 | ### Folder Structure 17 | All files in `/src` will be transpiled to `/lib` using [Babel](https://github.com/babel/babel/) after running `yarn watch` or `yarn build`. Anything in `/lib` will be overwritten. 18 | 19 | 20 | ## License 21 | 22 | MIT 23 | -------------------------------------------------------------------------------- /assets/add.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /assets/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /assets/pin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /assets/trash.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | notes -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "local-addon-notes", 3 | "productName": "Notes", 4 | "version": "1.2.3", 5 | "author": "Clay Griffiths", 6 | "keywords": [ 7 | "local-addon" 8 | ], 9 | "bgColor": "#51bb7b", 10 | "icon": "icon.svg", 11 | "slug": "notes", 12 | "description": "Add notes to your Local by Flywheel sites!", 13 | "renderer": "lib/renderer.js", 14 | "main": "lib/main.js", 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/getflywheel/local-addon-notes" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/getflywheel/local-addon-notes/issues" 21 | }, 22 | "license": "MIT", 23 | "scripts": { 24 | "build": "tsc", 25 | "watch": "yarn run build --watch", 26 | "prepare": "npm run build" 27 | }, 28 | "devDependencies": { 29 | "@getflywheel/eslint-config-local": "1.0.4", 30 | "@getflywheel/local": "^9.2.2", 31 | "@types/classnames": "^2.3.4", 32 | "@types/prop-types": "^15.7.14", 33 | "@types/react": "^19.0.12", 34 | "eslint": "^9.23.0", 35 | "eslint-plugin-import": "^2.31.0", 36 | "eslint-plugin-react": "^7.37.4", 37 | "typescript": "^5.8.2" 38 | }, 39 | "peerDependencies": { 40 | "react": ">= 16.4.0", 41 | "react-dom": ">= 16.4.0", 42 | "react-router-dom": "^4.3.1" 43 | }, 44 | "dependencies": { 45 | "@getflywheel/local-components": "^17.8.0", 46 | "classnames": "^2.5.1", 47 | "lodash": "^4.17.21", 48 | "path-to-regexp": "^8.2.0", 49 | "prop-types": "^15.6.2", 50 | "react": "^19.1.0", 51 | "react-dom": "^19.1.0", 52 | "react-router-dom": "^7.4.1" 53 | }, 54 | "bundledDependencies": [ 55 | "classnames", 56 | "lodash", 57 | "dateformat", 58 | "@getflywheel/local-components", 59 | "prop-types" 60 | ], 61 | "engines": { 62 | "local-by-flywheel": ">=6.5.2" 63 | }, 64 | "resolutions": { 65 | "trim": "1.0.1", 66 | "path-to-regexp": "1.9.0", 67 | "react-router": "7.5.2" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Note.jsx: -------------------------------------------------------------------------------- 1 | import { InnerPaneSidebarContentItem, Markdown } from '@getflywheel/local-components'; 2 | import React, { Component } from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import classnames from 'classnames'; 5 | import path from 'path'; 6 | 7 | export default class Note extends Component { 8 | state = { 9 | formattedDate: null, 10 | }; 11 | 12 | static propTypes = { 13 | date: PropTypes.any, 14 | body: PropTypes.string, 15 | pinned: PropTypes.bool, 16 | onPin: PropTypes.func, 17 | onDelete: PropTypes.func, 18 | onEdit: PropTypes.func, 19 | }; 20 | 21 | async componentDidMount() { 22 | const date = new Date(this.props.date); 23 | this.setState({ 24 | formattedDate: date.toLocaleDateString('en-US', { 25 | month: 'long', 26 | day: 'numeric', 27 | year: 'numeric' 28 | }), 29 | isLoading: false 30 | }); 31 | } 32 | 33 | renderButtons() { 34 | 35 | return
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 |
; 48 | 49 | } 50 | 51 | render() { 52 | const { formattedDate } = this.state 53 | return 54 |
{ formattedDate }
55 | 56 | {this.renderButtons()} 57 | 58 | 59 |
; 60 | 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/Notes.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | InnerPaneSidebar, 3 | InnerPaneSidebarHeader, 4 | InnerPaneSidebarAddNew, 5 | InnerPaneSidebarContent, 6 | EmptyArea, 7 | Button, 8 | } from '@getflywheel/local-components'; 9 | 10 | import React, { Component, Fragment } from 'react'; 11 | import Note from './Note'; 12 | import classnames from 'classnames'; 13 | import { ipcRenderer } from 'electron'; 14 | import { confirm } from '@getflywheel/local/renderer'; 15 | import path from 'path'; 16 | 17 | /** 18 | * @typedef {{ 19 | * body: string, 20 | * date: Date, 21 | * pinned: boolean, 22 | * }} NoteData 23 | * */ 24 | 25 | // The Note editor can be in these states... 26 | const EDITOR_STATES = { 27 | IDLE: 'idle', 28 | ADDING_NEW: 'adding-new', 29 | EDITING_EXISTING: 'editing-existing', 30 | }; 31 | 32 | export default class Notes extends Component { 33 | 34 | constructor(props) { 35 | 36 | super(props); 37 | 38 | /** @type {NoteData[]} */ 39 | const notes = this.fetchSiteNotes(); 40 | 41 | this.state = { 42 | promotePinned: false, 43 | notes, 44 | textareaValue: '', 45 | editorState: EDITOR_STATES.IDLE, 46 | editingNoteIndex: null, 47 | }; 48 | 49 | this.textareaRef = React.createRef(); 50 | 51 | this.onTextareaChange = this.onTextareaChange.bind(this); 52 | this.onTextareaKeyPress = this.onTextareaKeyPress.bind(this); 53 | this.onAddNoteOrCloseEditorClick = this.onAddNoteOrCloseEditorClick.bind(this); 54 | this.onEditNoteClick = this.onEditNoteClick.bind(this); 55 | 56 | } 57 | 58 | componentDidUpdate(previousProps) { 59 | 60 | if (previousProps.site.id !== this.props.site.id) { 61 | this.setState({ 62 | notes: this.fetchSiteNotes(), 63 | }); 64 | } 65 | 66 | } 67 | 68 | syncNotesToSite() { 69 | ipcRenderer.send('update-site-notes', this.props.site.id, this.state.notes); 70 | } 71 | 72 | fetchSiteNotes() { 73 | 74 | const notes = this.props.site.notes; 75 | 76 | if (!notes) { 77 | return []; 78 | } 79 | 80 | for (const [noteIndex, note] of notes.entries()) { 81 | if (note.date instanceof Date || !note.date) { 82 | continue; 83 | } 84 | 85 | notes[noteIndex].date = new Date(note.date); 86 | } 87 | 88 | return notes; 89 | 90 | } 91 | 92 | addNote(body) { 93 | 94 | const notes = this.state.notes.concat([{ 95 | date: new Date(), 96 | body, 97 | pinned: false, 98 | }]); 99 | 100 | this.setState({ 101 | notes, 102 | textareaValue: '', 103 | editorState: EDITOR_STATES.IDLE, 104 | }, this.syncNotesToSite); 105 | 106 | } 107 | 108 | updateNote(body) { 109 | 110 | const /** @type {number} */ editingNoteIndex = this.state.editingNoteIndex; 111 | const notes = this.state.notes.map((note, i) => { 112 | if (i !== editingNoteIndex) return note; 113 | return { 114 | ...note, 115 | body, 116 | }; 117 | }); 118 | 119 | this.setState({ 120 | notes, 121 | textareaValue: '', 122 | editorState: EDITOR_STATES.IDLE, 123 | }, this.syncNotesToSite); 124 | 125 | } 126 | 127 | onTextareaChange(event) { 128 | this.setState({ 129 | textareaValue: event.target.value, 130 | }); 131 | } 132 | 133 | onTextareaKeyPress(event) { 134 | 135 | if (event.key !== 'Enter' || event.altKey || event.shiftKey) { 136 | return; 137 | } 138 | 139 | event.preventDefault(); 140 | 141 | if (this.state.editorState === EDITOR_STATES.ADDING_NEW) { 142 | this.addNote(this.state.textareaValue); 143 | return; 144 | } 145 | 146 | if (this.state.editorState === EDITOR_STATES.EDITING_EXISTING) { 147 | this.updateNote(this.state.textareaValue); 148 | return; 149 | } 150 | 151 | console.log('Unexpected - User clicked Enter while note editor is open and previous code has not handled this case.'); 152 | console.log({ state: this.state, event }); 153 | console.log('-----\n'); 154 | 155 | 156 | } 157 | 158 | onAddNoteOrCloseEditorClick() { 159 | // NOTE: 160 | // In the UI the "Add New Note" button and the "Cancel Editing Note" are the same 161 | // DOM node, and on click this function is invoke. 162 | // TODO: migrate to two separate button if possible 163 | 164 | // if was open and adding new... 165 | if (this.state.editorState === EDITOR_STATES.ADDING_NEW) { 166 | this.setState({ 167 | editorState: EDITOR_STATES.IDLE, 168 | }); 169 | return; 170 | } 171 | 172 | // if was open and editing existing... 173 | if (this.state.editorState === EDITOR_STATES.EDITING_EXISTING) { 174 | this.setState({ 175 | editorState: EDITOR_STATES.IDLE, 176 | textareaValue: '', 177 | }); 178 | return; 179 | } 180 | 181 | // otherwsise... 182 | this.setState({ 183 | editorState: EDITOR_STATES.ADDING_NEW, 184 | }, () => { 185 | this.textareaRef.current.focus(); 186 | }); 187 | } 188 | 189 | onDeleteNote(note) { 190 | 191 | confirm({ 192 | title: 'Are you sure you want to delete this note?', 193 | buttonText: 'Delete Note', 194 | topIconColor: 'Orange', 195 | }).then(() => { 196 | const notes = this.state.notes; 197 | const noteIndex = notes.indexOf(note); 198 | 199 | if (noteIndex === -1) { 200 | return; 201 | } 202 | 203 | notes.splice(noteIndex, 1); 204 | 205 | this.setState({ 206 | notes, 207 | }, this.syncNotesToSite); 208 | }); 209 | 210 | } 211 | 212 | onPinNote(note) { 213 | 214 | const notes = this.state.notes; 215 | const noteIndex = this.state.notes.indexOf(note); 216 | 217 | if (noteIndex === -1) { 218 | return; 219 | } 220 | 221 | notes[noteIndex].pinned = !this.state.notes[noteIndex].pinned; 222 | 223 | this.setState({ 224 | notes, 225 | }, this.syncNotesToSite); 226 | 227 | } 228 | 229 | onEditNoteClick(/** @type {NoteData} */ note) { 230 | 231 | const noteIndex = this.state.notes.findIndex(n => n.date === note.date); 232 | if (noteIndex === -1) { 233 | throw new Error('Unexpected - User clikcke on "Edit" Note but the note is not in the app state! Plase inpsect the code! This must never happens!'); 234 | } 235 | 236 | this.setState({ 237 | editorState: EDITOR_STATES.EDITING_EXISTING, 238 | editingNoteIndex: noteIndex, 239 | textareaValue: note.body ?? '', 240 | }); 241 | } 242 | 243 | getNotesInOrder() { 244 | 245 | const notes = this.state.notes.slice(0); 246 | 247 | if (this.state.promotePinned) { 248 | return notes.sort((a, b) => { 249 | if (a.pinned && !b.pinned) { 250 | return -1; 251 | } else if (!a.pinned && b.pinned) { 252 | return 1; 253 | } 254 | 255 | return b.date - a.date; 256 | }); 257 | } 258 | 259 | return notes.sort((a, b) => b.date - a.date); 260 | 261 | } 262 | 263 | renderNotes() { 264 | 265 | if (!this.state.notes || !this.state.notes.length) { 266 | if (this.state.editorState === EDITOR_STATES.IDLE) return ( 267 | 268 | No notes added
269 | to this site

270 | 271 |
272 | ); 273 | } 274 | 275 | return this.getNotesInOrder().map((note) => ( 276 | this.onDeleteNote(note)} 282 | onPin={() => this.onPinNote(note)} 283 | onEdit={() => this.onEditNoteClick(note)} 284 | /> 285 | )); 286 | 287 | } 288 | 289 | pinnedNotesCount() { 290 | return this.state.notes.filter((note) => note.pinned).length; 291 | } 292 | 293 | renderButtons() { 294 | 295 | return 296 | this.setState({ promotePinned: !this.state.promotePinned })} 297 | className={classnames('PromotePinned', { '--Enabled': this.state.promotePinned })}> 298 | 299 | 300 | 301 | {this.pinnedNotesCount() ? {this.pinnedNotesCount()} : ''} 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | ; 310 | 311 | } 312 | 313 | renderEditor() { 314 | return ( 315 | 316 |