├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── index.html ├── rollup.config.js ├── src ├── App.svelte ├── components │ ├── BoardLabel.svelte │ ├── ConfirmModal.svelte │ ├── CreateBoard.svelte │ ├── EditableTitle.svelte │ ├── SearchPalette.svelte │ ├── base │ │ ├── Editor.svelte │ │ ├── Modal.svelte │ │ └── Popover.svelte │ ├── card │ │ ├── CardDue.svelte │ │ ├── CardLabel.svelte │ │ └── CardPriority.svelte │ ├── column │ │ ├── InlineEditor.svelte │ │ ├── Layout.svelte │ │ ├── List.svelte │ │ ├── ListCard.svelte │ │ └── ListMenu.svelte │ └── zen │ │ └── Multiselect.svelte ├── datastore │ ├── db.ts │ ├── models │ │ ├── Board.ts │ │ ├── Card.ts │ │ ├── List.ts │ │ ├── Settings.ts │ │ └── index.ts │ └── stores.ts ├── main.ts ├── nimbo.ts ├── routes │ ├── Board.svelte │ ├── Card.svelte │ ├── Home.svelte │ ├── Zen.svelte │ └── index.ts ├── style │ └── TailwindCSS.svelte ├── types.ts └── util │ └── index.ts ├── tailwind.config.js └── tsconfig.json /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - v** 7 | 8 | jobs: 9 | build: 10 | name: Create release 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Check out repo 15 | uses: actions/checkout@v2 16 | - name: Install NodeJS 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: '12.x' 20 | - name: Build release 21 | run: | 22 | npm install 23 | npm run build-release 24 | - name: Get version 25 | id: nimbo 26 | run: | 27 | echo ::set-output name=VERSION::$(git describe --tags) 28 | - name: Create release 29 | id: create_release 30 | uses: actions/create-release@v1 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | with: 34 | tag_name: ${{ steps.nimbo.outputs.VERSION }} 35 | release_name: nimbo ${{ steps.nimbo.outputs.VERSION }} 36 | draft: false 37 | prerelease: false 38 | - name: Upload release 39 | id: upload-release-asset 40 | uses: actions/upload-release-asset@v1 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 43 | with: 44 | upload_url: ${{ steps.create_release.outputs.upload_url }} 45 | asset_path: ./public/nimbo.zip 46 | asset_name: nimbo.zip 47 | asset_content_type: application/zip -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/assets/ 3 | 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 sereneblue 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nimbo 2 | 3 | ![nimbo version](https://img.shields.io/badge/version-1.0.6-brightgreen.svg) 4 | ![MIT License](https://img.shields.io/badge/license-MIT-blue.svg) 5 | 6 | The nimble Kanban board. 7 | 8 | ## Installation 9 | 10 | You can try nimbo [here](https://sereneblue.github.io/nimbo) or grab a local copy from the [releases](https://github.com/sereneblue/nimbo/releases) page. 11 | 12 | ![screenshot](https://user-images.githubusercontent.com/14242625/103376268-2fb1a880-4aaa-11eb-8964-8da53a387e20.png) 13 | 14 | ## Features 15 | 16 | - Works offline, data stored in IndexedDB 17 | - Search for board and cards instantly 18 | - Keyboard shortcuts 19 | - Light/dark theme 20 | - Import/export data between browsers 21 | - Import boards from Trello 22 | - Archive and star boards 23 | - Card checklist, description, due date, labels, and priority 24 | - Markdown support for card descriptions 25 | - Changes are synced between tabs 26 | - Zen mode with time tracking 27 | 28 | See more [here](https://sereneblue.github.io/nimbo/about). -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nimbo", 3 | "version": "1.0.6", 4 | "scripts": { 5 | "build": "rollup -c --environment NODE_ENV:production", 6 | "build-release": "npm run build && cd public && rm assets/*.map && zip -r nimbo.zip .", 7 | "dev": "rollup -c -w", 8 | "start": "sirv public --single", 9 | "validate": "svelte-check" 10 | }, 11 | "devDependencies": { 12 | "@rollup/plugin-commonjs": "^14.0.0", 13 | "@rollup/plugin-node-resolve": "^8.0.0", 14 | "@rollup/plugin-replace": "^2.3.4", 15 | "@rollup/plugin-typescript": "^4.0.0", 16 | "@tailwindcss/custom-forms": "^0.2.1", 17 | "@tsconfig/svelte": "^1.0.0", 18 | "autoprefixer": "^9.8.6", 19 | "cssnano": "^4.1.10", 20 | "postcss": "^7.0.35", 21 | "postcss-load-config": "^2.1.2", 22 | "rollup": "^2.35.1", 23 | "rollup-plugin-css-only": "^2.1.0", 24 | "rollup-plugin-livereload": "^2.0.0", 25 | "rollup-plugin-svelte": "^6.1.1", 26 | "rollup-plugin-terser": "^7.0.0", 27 | "svelte": "^3.31.0", 28 | "svelte-check": "^1.1.23", 29 | "svelte-preprocess": "^4.6.1", 30 | "tailwindcss": "^1.9.6", 31 | "tailwindcss-truncate-multiline": "^1.0.3", 32 | "tslib": "^2.0.3", 33 | "typescript": "^3.9.3" 34 | }, 35 | "dependencies": { 36 | "broadcast-channel": "^3.3.0", 37 | "dexie": "^3.0.3", 38 | "dexie-export-import": "^1.0.0", 39 | "dompurify": "^2.2.6", 40 | "file-saver": "^2.0.5", 41 | "flatpickr": "^4.6.8", 42 | "fuzzysort": "^1.1.4", 43 | "marked": "^1.2.7", 44 | "nanoid": "^3.1.20", 45 | "parse-duration": "^0.4.4", 46 | "sirv-cli": "^1.0.10", 47 | "sortablejs": "^1.12.0", 48 | "svelte-spa-router": "^3.1.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const prod = !process.env.ROLLUP_WATCH; 2 | 3 | module.exports = { 4 | plugins: [ 5 | require('tailwindcss'), 6 | ...(prod 7 | ? [ 8 | require('autoprefixer'), 9 | require('cssnano')({ 10 | preset: ['default', { discardComments: { removeAll: true } }], 11 | }), 12 | ] 13 | : []), 14 | ], 15 | }; -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | nimbo 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte'; 2 | import replace from '@rollup/plugin-replace'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import css from "rollup-plugin-css-only"; 6 | import livereload from 'rollup-plugin-livereload'; 7 | import { terser } from 'rollup-plugin-terser'; 8 | import sveltePreprocess from 'svelte-preprocess'; 9 | import typescript from '@rollup/plugin-typescript'; 10 | 11 | const production = !process.env.ROLLUP_WATCH; 12 | 13 | function serve() { 14 | let server; 15 | 16 | function toExit() { 17 | if (server) server.kill(0); 18 | } 19 | 20 | return { 21 | writeBundle() { 22 | if (server) return; 23 | server = require('child_process').spawn( 24 | 'npm', 25 | ['run', 'start', '--', '--dev'], 26 | { 27 | stdio: ['ignore', 'inherit', 'inherit'], 28 | shell: true, 29 | } 30 | ); 31 | 32 | process.on('SIGTERM', toExit); 33 | process.on('exit', toExit); 34 | }, 35 | }; 36 | } 37 | 38 | export default { 39 | input: 'src/main.ts', 40 | output: { 41 | sourcemap: true, 42 | format: 'iife', 43 | name: 'app', 44 | file: 'public/assets/bundle.js', 45 | }, 46 | plugins: [ 47 | replace({ 48 | 'process.env.NODE_ENV': production ? JSON.stringify( 'production' ) : JSON.stringify( 'development' ) 49 | }), 50 | css({ output: "public/assets/extra.css" }), 51 | svelte({ 52 | // enable run-time checks when not in production 53 | dev: !production, 54 | // we'll extract any component CSS out into 55 | // a separate file - better for performance 56 | css: (css) => { 57 | css.write('bundle.css'); 58 | }, 59 | preprocess: sveltePreprocess({ postcss: true }), 60 | }), 61 | 62 | // If you have external dependencies installed from 63 | // npm, you'll most likely need these plugins. In 64 | // some cases you'll need additional configuration - 65 | // consult the documentation for details: 66 | // https://github.com/rollup/plugins/tree/master/packages/commonjs 67 | resolve({ 68 | browser: true, 69 | dedupe: ['svelte'], 70 | }), 71 | commonjs(), 72 | typescript({ sourceMap: !production }), 73 | 74 | // In dev mode, call `npm run start` once 75 | // the bundle has been generated 76 | !production && serve(), 77 | 78 | // Watch the `public` directory and refresh the 79 | // browser on changes when not in production 80 | !production && livereload('public'), 81 | 82 | // If we're building for production (npm run build 83 | // instead of npm run dev), minify 84 | production && terser(), 85 | ], 86 | watch: { 87 | clearScreen: false, 88 | }, 89 | }; -------------------------------------------------------------------------------- /src/App.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | 39 | {#await loading then done} 40 |
41 | 42 |
43 | {/await} 44 | -------------------------------------------------------------------------------- /src/components/BoardLabel.svelte: -------------------------------------------------------------------------------- 1 | 51 | 52 |
53 | 54 | 55 | 56 |
    57 | {#each labels as l (l)} 58 |
  • 59 |
    60 | 69 | {#if selectedColor == l.color} 70 | 71 | {:else} 72 | 73 | {l.text} 74 | 75 | {/if} 76 |
    77 | 82 |
  • 83 | {/each} 84 |
85 |
86 |
-------------------------------------------------------------------------------- /src/components/ConfirmModal.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 |
25 |

26 | {modalMessage} 27 |

28 |
29 | 32 | 35 |
36 |
37 |
-------------------------------------------------------------------------------- /src/components/CreateBoard.svelte: -------------------------------------------------------------------------------- 1 | 61 | 62 | 63 |
64 |
65 | 66 | 67 | 70 | 71 |
72 |
73 | OR 74 |
75 |
76 | 77 |
78 | Import from Trello. 79 | Export board as JSON and paste (CTRL + V) here! 80 |
81 | 82 | {#if couldNotImport} 83 |
84 | Could not import board! 85 |
86 | {/if} 87 |
88 |
89 |
-------------------------------------------------------------------------------- /src/components/EditableTitle.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | 62 | 63 |
64 |
65 | {#if isTitle} 66 |

67 | 68 |

69 | {:else} 70 |

71 | 72 |

73 | {/if} 74 |
75 |
-------------------------------------------------------------------------------- /src/components/SearchPalette.svelte: -------------------------------------------------------------------------------- 1 | 132 | 133 | 142 | 143 | {#if $nimboStore.showCommandPalette } 144 |
145 |
146 |
147 | 148 | 149 |
150 | 151 |
    152 | {#each searchResults as s} 153 |
  • handleResultsEvent(e, s) } on:click={e => execAction(s) } tabindex="0"> 154 | {#if s.obj.type == RESULT_TYPE.BOARD} 155 | Board 156 | {:else if s.obj.type == RESULT_TYPE.CARD} 157 | Card 158 | {/if} 159 | {#if s.target} 160 | {@html fuzzysort.highlight(s, '', '')} 161 | {:else} 162 | {s.obj.text} 163 | {/if} 164 |
  • 165 | {/each} 166 |
167 |
168 |
169 | {/if} -------------------------------------------------------------------------------- /src/components/base/Editor.svelte: -------------------------------------------------------------------------------- 1 | 277 | 278 |
279 |
280 | {#if !inline} 281 |
282 |
283 | 284 |
285 |
286 | 287 | {cardDetails.board.title} 288 | 289 | / {cardDetails.list.title} 290 |
291 |
292 | {/if} 293 |
294 | {#if inline} 295 |
296 |
297 | 298 |
299 | 300 | 301 | 302 |
303 | {/if} 304 | 305 |
306 |
307 | 308 | 309 |
310 |
311 |
312 |

313 | Description 314 |

315 |
316 | {#if editDescription || cardDetails.card.description } 317 |
318 | 319 |
320 | {/if} 321 |
322 |
323 | {#if cardDetails.card.description && !editDescription} 324 |
325 | {@html DOMPurify.sanitize(marked(cardDetails.card.description))} 326 |
327 | {:else} 328 | {#if editDescription} 329 | 330 | {:else} 331 |
Add a description
332 | {/if} 333 | {/if} 334 |
335 |
336 | 337 |
338 |
339 |
340 |
341 |

342 | Due Date 343 |

344 |
345 |
346 |
347 | 348 |
349 |
350 |
351 |
352 |
353 |

354 | Priority 355 |

356 |
357 |
358 |
359 | 360 |
361 |
362 |
363 |
364 |
365 |

366 | Label 367 |

368 |
369 |
370 |
371 | 372 |
373 |
374 | {#if isZen} 375 |
376 |
377 |
378 |

379 | List 380 |

381 |
382 |
383 |
384 | 389 |
390 |
391 | {/if} 392 |
393 | 394 |
395 |
396 | {#if !inline} 397 |
398 | 399 |
400 | {/if} 401 |
402 |

403 | 404 | Checklist 405 | {checklistProgress} 406 | 407 | {#if cardDetails.card.checklist.length} 408 | 409 | 416 | 417 | 418 | {/if} 419 |

420 |
421 |
422 |
423 | {#if cardDetails.card.checklist.length} 424 |
425 |
426 |
427 | {/if} 428 |
    429 | {#each cardDetails.card.checklist as cl, index (cl)} 430 | {#if (hideCompleted && !cl.checked) || hideCompleted == false } 431 |
  • 432 | 436 | 439 |
  • 440 | {/if} 441 | {/each} 442 |
443 |
444 | 449 |
450 |
451 |
452 | 453 |
454 |
455 | {#if !inline} 456 |
457 | 458 |
459 | {/if} 460 |
461 |

462 | 463 | Time Tracking 464 | {formatTime(totalTime)} 465 | 466 |

467 |
468 |
469 |
470 |
    471 | {#each cardDetails.card.log as l, index (l)} 472 |
  • 473 |
    474 |
    {formatTime(l.duration)}
    475 |
    {formatDate(l.date)}
    476 |
    477 | 480 |
  • 481 | {/each} 482 |
483 |
484 | 489 |
490 |
491 |
492 | 493 |
494 | 497 |
498 |
-------------------------------------------------------------------------------- /src/components/base/Modal.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 | {#if isOpen } 34 |
35 |
36 | 37 |
38 |
39 | {/if} -------------------------------------------------------------------------------- /src/components/base/Popover.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | {#if isOpen } 20 |
21 | 22 |
23 | {/if} -------------------------------------------------------------------------------- /src/components/card/CardDue.svelte: -------------------------------------------------------------------------------- 1 | 63 | 64 | 217 | 218 |
219 | {#if date} 220 | 221 | 222 | 223 | {/if} 224 | 225 | {#if !complete && date} 226 | 229 | {/if} 230 |
-------------------------------------------------------------------------------- /src/components/card/CardLabel.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
38 |
39 | 40 | 65 | 66 | 67 | 68 |
    69 | {#each labels as l (l)} 70 |
  • handleSelectLabel(l.color)} data-label={l.color} role="option" class="hover:bg-light-200 dark:hover:bg-dark-100 dark:hover:bg-dark-100 cursor-pointer select-none relative py-2 pl-3 pr-9"> 71 |
    72 | 81 | 82 | {l.text} 83 | 84 |
    85 | 86 | {#if selected === l.color} 87 | 88 | 89 | 90 | 91 | 92 | {/if} 93 |
  • 94 | {/each} 95 |
96 |
97 |
98 |
-------------------------------------------------------------------------------- /src/components/card/CardPriority.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 47 | 48 |
49 | 52 | 55 | 58 | 61 |
-------------------------------------------------------------------------------- /src/components/column/InlineEditor.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 |
49 |
55 | {#if id} 56 | 60 | {/if} 61 |
62 |
-------------------------------------------------------------------------------- /src/components/column/Layout.svelte: -------------------------------------------------------------------------------- 1 | 100 | 101 | 102 | 103 | 108 | 109 |
110 | 111 | 112 | {#each board.lists as l (l.id)} 113 |
114 | 120 |
121 | {/each} 122 |
123 | 129 |
130 |
-------------------------------------------------------------------------------- /src/components/column/List.svelte: -------------------------------------------------------------------------------- 1 | 103 | 104 | 115 | 116 |
117 |
118 |
119 | 120 |
121 |
122 | 123 |
124 |
125 | 128 |
129 | 130 | 141 |
142 | 143 |
144 | {#each list.cards as c (c.id)} 145 |
handleCardClick(c.id, list.index)}> 146 | 147 |
148 | {/each} 149 |
150 | 151 |
152 | 157 |
158 |
-------------------------------------------------------------------------------- /src/components/column/ListCard.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 |
48 | {#if card.label != null} 49 |
50 |
59 |
60 | {/if} 61 |
62 | {card.title} 63 |
64 | {#if hasMoreDetails} 65 |
66 |
67 | {#if card.description} 68 |
69 | 70 |
71 | {/if} 72 | {#if card.checklist.length} 73 |
74 | 75 | {checklistCompleted} / {card.checklist.length} 76 |
77 | {/if} 78 | {#if card.log.length} 79 |
80 | 81 | {formatTime(totalTrackedTime)} 82 |
83 | {/if} 84 | {#if card.due} 85 |
92 | 93 | {formatDate(card.due)} 94 |
95 | {/if} 96 | {#if card.priority in PRIORITY} 97 |
98 | {#if card.priority == PRIORITY.P1} 99 | 100 | P1 101 | 102 | {:else if card.priority == PRIORITY.P2} 103 | 104 | P2 105 | 106 | {:else if card.priority == PRIORITY.P3} 107 | 108 | P3 109 | 110 | {:else if card.priority == PRIORITY.P4} 111 | 112 | P4 113 | 114 | {/if} 115 |
116 | {/if} 117 |
118 |
119 | {/if} 120 | {#if isZen} 121 |
122 | 133 |
134 | {/if} 135 |
-------------------------------------------------------------------------------- /src/components/column/ListMenu.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | {#if isOpen} 25 |
29 |
30 | 100 |
101 |
102 | {/if} -------------------------------------------------------------------------------- /src/components/zen/Multiselect.svelte: -------------------------------------------------------------------------------- 1 | 93 | 94 | 100 | 101 |
102 |
104 | {displayText} 105 |
106 | {#if open} 107 |
108 |
109 | {#each dropdownOptions as o (o.value)} 110 | 114 | {/each} 115 |
116 |
117 | {/if} 118 |
-------------------------------------------------------------------------------- /src/datastore/db.ts: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie'; 2 | import { Board, Card, List, Settings } from './models'; 3 | 4 | export class nimboDB extends Dexie { 5 | boards: Dexie.Table; 6 | cards: Dexie.Table; 7 | lists: Dexie.Table; 8 | settings: Dexie.Table; 9 | 10 | constructor() { 11 | super("nimboDB"); 12 | 13 | let db = this; 14 | 15 | db.version(1).stores({ 16 | boards: "&id, title", 17 | cards: "&id, title, listId, index", 18 | lists: "&id, boardId, index", 19 | settings: "&id" 20 | }); 21 | 22 | db.boards.mapToClass(Board); 23 | db.cards.mapToClass(Card); 24 | db.lists.mapToClass(List); 25 | db.settings.mapToClass(Settings); 26 | } 27 | } -------------------------------------------------------------------------------- /src/datastore/models/Board.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from '../../util'; 2 | import { BOARD_COLORS, BoardColor, LABEL_COLOR } from '../../types'; 3 | import type { BoardLabel, SwapObject } from '../../types'; 4 | import type List from './List'; 5 | import type Card from './Card'; 6 | 7 | const BOARD_LABELS: BoardLabel[] = [ 8 | { color: LABEL_COLOR.RED, text: "Red" }, 9 | { color: LABEL_COLOR.ORANGE, text: "Orange" }, 10 | { color: LABEL_COLOR.YELLOW, text: "Yellow" }, 11 | { color: LABEL_COLOR.GREEN, text: "Green" }, 12 | { color: LABEL_COLOR.TEAL, text: "Teal" }, 13 | { color: LABEL_COLOR.BLUE, text: "Blue" }, 14 | { color: LABEL_COLOR.PURPLE, text: "Purple" } 15 | ]; 16 | 17 | export default class Board { 18 | id: string; 19 | title: string; 20 | color: number; 21 | isArchived: boolean; 22 | isStarred: boolean; 23 | labels: BoardLabel[]; 24 | lastViewTime: number; 25 | lists: List[]; 26 | 27 | constructor(title: string) { 28 | this.id = nanoid(); 29 | this.title = title; 30 | this.isArchived = false; 31 | this.isStarred = false; 32 | this.lastViewTime = new Date().getTime(); 33 | this.color = this.getRandomColor(); 34 | this.lists = new Array(); 35 | this.labels = BOARD_LABELS; 36 | 37 | Object.defineProperties(this, { 38 | lists: { value: [], enumerable: false, writable: true } 39 | }); 40 | } 41 | 42 | private getRandomColor(): number { 43 | return Math.floor(Math.random() * BOARD_COLORS.length); 44 | } 45 | 46 | setLabels(labels: BoardLabel[]): void { 47 | this.labels = labels; 48 | } 49 | 50 | setLastViewTime(timestamp: number): void { 51 | this.lastViewTime = timestamp; 52 | } 53 | 54 | setTitle(title: string): void { 55 | this.title = title; 56 | } 57 | 58 | swapLists(l: SwapObject): void { 59 | this.lists[l.from].index = l.to; 60 | this.lists[l.to].index = l.from; 61 | 62 | let tmp: List = this.lists[l.from]; 63 | this.lists[l.from] = this.lists[l.to]; 64 | this.lists[l.to] = tmp; 65 | } 66 | 67 | toggleBoardArchive(): void { 68 | this.isArchived = !this.isArchived; 69 | } 70 | 71 | toggleBoardStar(): void { 72 | this.isStarred = !this.isStarred; 73 | } 74 | } -------------------------------------------------------------------------------- /src/datastore/models/Card.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from '../../util'; 2 | import type List from './List'; 3 | import type { ChecklistItem, PRIORITY, LABEL_COLOR, TimeEntry } from '../../types'; 4 | 5 | export default class Card { 6 | created: number; 7 | checklist: ChecklistItem[]; 8 | description: string; 9 | due: number; 10 | id: string; 11 | isComplete: boolean; 12 | index: number; 13 | label: LABEL_COLOR; 14 | listId: string; 15 | log: TimeEntry[]; 16 | priority: PRIORITY; 17 | title: string; 18 | 19 | constructor(list: List, title: string) { 20 | this.id = nanoid(); 21 | this.checklist = new Array(); 22 | this.created = new Date().getTime(); 23 | this.description = ""; 24 | this.index = list.cards.length; 25 | this.label = null; 26 | this.listId = list.id; 27 | this.log = new Array(); 28 | this.title = title; 29 | } 30 | 31 | addToChecklist(text: string) { 32 | this.checklist.push({ 33 | checked: false, 34 | text 35 | }); 36 | } 37 | 38 | deleteFromChecklist(index: number) { 39 | this.checklist.splice(index, 1); 40 | } 41 | 42 | deleteTimeEntry(index: number) { 43 | this.log.splice(index, 1); 44 | } 45 | 46 | setComplete(complete: boolean) { 47 | this.isComplete = complete; 48 | } 49 | 50 | setDueDate(timestamp: number) { 51 | this.due = timestamp; 52 | } 53 | 54 | setDescription(text: string) { 55 | this.description = text; 56 | } 57 | 58 | setLabel(label: LABEL_COLOR) { 59 | this.label = label; 60 | } 61 | 62 | setPriority(priority: PRIORITY) { 63 | this.priority = priority; 64 | } 65 | 66 | setTitle(title: string) { 67 | this.title = title; 68 | } 69 | 70 | trackTime(duration: number) { 71 | this.log.push({ 72 | duration, 73 | date: new Date().getTime() 74 | }); 75 | } 76 | } -------------------------------------------------------------------------------- /src/datastore/models/List.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from '../../util'; 2 | import type Card from './Card'; 3 | 4 | export default class List { 5 | id: string; 6 | boardId: string; 7 | index: number; 8 | title: string; 9 | cards: Card[]; 10 | 11 | constructor(boardId: string, index: number) { 12 | this.id = nanoid(); 13 | this.title = "New List"; 14 | this.index = index; 15 | this.boardId = boardId; 16 | this.cards = new Array(); 17 | 18 | Object.defineProperties(this, { 19 | cards: { value: [], enumerable: false, writable: true } 20 | }); 21 | } 22 | 23 | deleteCard(cardId: string): void { 24 | let cardIndex: number = this.cards.findIndex(c => c.id === cardId); 25 | 26 | if (cardIndex > -1) { 27 | this.cards.splice(cardIndex, 1); 28 | } 29 | } 30 | 31 | setTitle(title: string): void { 32 | this.title = title; 33 | } 34 | } -------------------------------------------------------------------------------- /src/datastore/models/Settings.ts: -------------------------------------------------------------------------------- 1 | import type { DueFilterOptions, Theme } from '../../types'; 2 | 3 | export default class Settings { 4 | id: number; 5 | dueFilter: DueFilterOptions; 6 | listFilter: string; 7 | maxCards: number; 8 | theme: Theme; 9 | selectedBoards: string[]; 10 | 11 | constructor() { 12 | this.id = 1; 13 | this.theme = "dark"; 14 | this.maxCards = 5; 15 | this.dueFilter = "all"; 16 | this.listFilter = ""; 17 | this.selectedBoards = ["all"]; 18 | } 19 | } -------------------------------------------------------------------------------- /src/datastore/models/index.ts: -------------------------------------------------------------------------------- 1 | import Board from "./Board"; 2 | import Card from "./Card"; 3 | import List from "./List"; 4 | import Settings from "./Settings"; 5 | 6 | export { 7 | Board, 8 | Card, 9 | List, 10 | Settings 11 | } -------------------------------------------------------------------------------- /src/datastore/stores.ts: -------------------------------------------------------------------------------- 1 | import { writable, Writable } from 'svelte/store'; 2 | import nimbo from '../nimbo'; 3 | 4 | export const nimboStore: Writable = writable(new nimbo()) -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import App from './App.svelte'; 2 | 3 | const app = new App({ 4 | target: document.body 5 | }); 6 | 7 | export default app; -------------------------------------------------------------------------------- /src/nimbo.ts: -------------------------------------------------------------------------------- 1 | import { exportDB, peakImportFile} from "dexie-export-import"; 2 | import { nimboDB } from './datastore/db'; 3 | import type { BroadcastChannel } from 'broadcast-channel'; 4 | import { Board, Card, List, Settings } from './datastore/models'; 5 | import { BoardLabel, CardDetails, PRIORITY, RESULT_TYPE, SearchObject, SortObject, SwapObject, Theme } from './types'; 6 | import { getTimestamps } from './util'; 7 | 8 | export default class nimbo { 9 | db: nimboDB; 10 | boards: Board[]; 11 | channel: BroadcastChannel; 12 | selectedCardId: string; 13 | settings: Settings; 14 | showCommandPalette: boolean; 15 | 16 | constructor() { 17 | this.db = new nimboDB(); 18 | this.boards = new Array(); 19 | this.selectedCardId = null; 20 | this.settings = new Settings(); 21 | this.showCommandPalette = false; 22 | } 23 | 24 | public async init(): Promise { 25 | this.boards = await this.db.boards.toArray(); 26 | 27 | let lists: List[][] = await Promise.all(this.boards.map(b => this.db.lists.where({ boardId: b.id }).toArray())); 28 | 29 | for (let i: number = 0; i < lists.length; i++) { 30 | this.boards[i].lists = lists[i].sort((a: List, b: List): number => { 31 | return a.index - b.index 32 | }); 33 | 34 | let cards: Card[][] = await Promise.all(this.boards[i].lists.map(l => this.db.cards.where({ listId: l.id }).toArray())); 35 | 36 | for (let j: number = 0; j < this.boards[i].lists.length; j++) { 37 | this.boards[i].lists[j].cards = cards[j].sort((a: Card, b: Card): number => { 38 | return a.index - b.index 39 | }); 40 | } 41 | } 42 | 43 | let settings: Settings[] = await this.db.settings.toArray(); 44 | if (settings.length === 0) { 45 | await this.updateSettings(this.settings); 46 | } else { 47 | this.settings = settings[0]; 48 | } 49 | 50 | return true; 51 | } 52 | 53 | public openPalette() { 54 | this.showCommandPalette = true; 55 | } 56 | 57 | public closePalette() { 58 | this.showCommandPalette = false; 59 | } 60 | 61 | public async addNewCard(list: List, title: string): Promise { 62 | let boardIndex: number = this.boards.findIndex(b => b.id === list.boardId); 63 | 64 | if (boardIndex > -1) { 65 | let listIndex: number = this.boards[boardIndex].lists.findIndex(l => l.id === list.id); 66 | 67 | if (listIndex > -1) { 68 | let c: Card = new Card(list, title); 69 | 70 | this.boards[boardIndex].lists[listIndex].cards = [...this.boards[boardIndex].lists[listIndex].cards, c]; 71 | 72 | await this.db.cards.add(c); 73 | } 74 | } 75 | 76 | this.update(); 77 | } 78 | 79 | public async addNewList(boardId: string, index: number = null): Promise { 80 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 81 | 82 | if (boardIndex > -1) { 83 | let l: List = new List(boardId, this.boards[boardIndex].lists.length); 84 | 85 | if (index != null) { 86 | let listIndexes: object = {}; 87 | 88 | this.boards[boardIndex].lists.splice(index + 1, 0, l); 89 | this.boards[boardIndex].lists = this.boards[boardIndex].lists; 90 | 91 | for (let i: number = 0; i < this.boards[boardIndex].lists.length; i++) { 92 | this.boards[boardIndex].lists[i].index = i; 93 | 94 | listIndexes[this.boards[boardIndex].lists[i].id] = i; 95 | }; 96 | 97 | await this.updateListIndexes(listIndexes); 98 | } else { 99 | this.boards[boardIndex].lists = [...this.boards[boardIndex].lists, l]; 100 | } 101 | 102 | await this.db.lists.add(l); 103 | } 104 | 105 | this.update(); 106 | } 107 | 108 | public async createBoard(title: string): Promise { 109 | let b: Board = new Board(title); 110 | this.boards = [...this.boards, b]; 111 | 112 | await this.db.boards.add(b); 113 | 114 | this.update(); 115 | 116 | return b.id; 117 | } 118 | 119 | public async deleteBoard(id: string): Promise { 120 | let boardIndex: number = this.boards.findIndex(b => b.id === id); 121 | 122 | if (boardIndex > -1) { 123 | this.boards.splice(boardIndex, 1); 124 | } 125 | 126 | await this.db.boards.delete(id); 127 | 128 | this.update(); 129 | } 130 | 131 | public async deleteCard(list: List, cardId: string): Promise { 132 | for (let i: number = 0; i < this.boards.length; i++) { 133 | for (let j: number = 0; j < this.boards[i].lists.length; j++) { 134 | if (this.boards[i].lists[j].id === list.id) { 135 | list.deleteCard(cardId); 136 | 137 | this.boards[i].lists[j] = list; 138 | } 139 | } 140 | } 141 | 142 | await this.db.cards.delete(cardId); 143 | 144 | this.update(); 145 | } 146 | 147 | public async deleteList(boardId: string, listId: string): Promise { 148 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 149 | 150 | if (boardIndex > -1) { 151 | let listIndex: number = this.boards[boardIndex].lists.findIndex(l => l.id === listId); 152 | 153 | if (listIndex > -1) { 154 | let listIndexes: object = {}; 155 | 156 | this.boards[boardIndex].lists.splice(listIndex, 1); 157 | 158 | for (let i: number = 0; i < this.boards[boardIndex].lists.length; i++) { 159 | this.boards[boardIndex].lists[i].index = i; 160 | 161 | listIndexes[this.boards[boardIndex].lists[i].id] = i; 162 | }; 163 | 164 | await this.db.transaction('rw', this.db.lists, this.db.cards, async (): Promise =>{ 165 | await Promise.all([ 166 | this.db.lists.delete(listId), 167 | this.db.cards.where({ listId }).delete() 168 | ]); 169 | }); 170 | 171 | await this.updateListIndexes(listIndexes); 172 | } 173 | } 174 | 175 | this.update(); 176 | } 177 | 178 | public async export(): Promise { 179 | return await exportDB(this.db); 180 | } 181 | 182 | public everything(): SearchObject[] { 183 | return (this.boards.map((b) => { 184 | if (b.isArchived) return []; 185 | 186 | return [{ 187 | type: RESULT_TYPE.BOARD, 188 | text: b.title, 189 | path: `/b/${b.id}` 190 | }, b.lists.map(l => { 191 | return l.cards.map(c => { 192 | return { 193 | type: RESULT_TYPE.CARD, 194 | text: c.title, 195 | path: `/c/${c.id}` 196 | }; 197 | }); 198 | }) 199 | ]; 200 | }) as any).flat(4); 201 | } 202 | 203 | public getBoard(boardId: string): Board | null { 204 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 205 | 206 | if (boardIndex > -1) { 207 | return this.boards[boardIndex]; 208 | } 209 | 210 | return null; 211 | } 212 | 213 | public getCardDetails(cardId: string): CardDetails | null { 214 | for (let i: number = 0; i < this.boards.length; i++) { 215 | for (let j: number = 0; j < this.boards[i].lists.length; j++) { 216 | for (let k: number = 0; k < this.boards[i].lists[j].cards.length; k++) { 217 | if (this.boards[i].lists[j].cards[k].id === cardId) { 218 | return { 219 | board: this.boards[i], 220 | list: this.boards[i].lists[j], 221 | card: this.boards[i].lists[j].cards[k] 222 | } 223 | } 224 | } 225 | } 226 | } 227 | 228 | return null; 229 | } 230 | 231 | public async import(db: File): Promise { 232 | try { 233 | let e = await peakImportFile(db); 234 | 235 | if (e.formatName == "dexie" && e.formatVersion == 1) { 236 | await this.db.import(db, { 237 | clearTablesBeforeImport: true 238 | }); 239 | 240 | this.update(); 241 | return true; 242 | } 243 | } catch (err) {}; 244 | 245 | return; 246 | } 247 | 248 | public async importTrelloBoard(b: any): Promise { 249 | let board: Board = new Board(b.name); 250 | 251 | board.lastViewTime = new Date(b.dateLastView).getTime(); 252 | board.isStarred = b.starred; 253 | board.isArchived = b.closed; 254 | 255 | let cards: Card[] = []; 256 | let lists: List[] = []; 257 | 258 | for (let i: number = 0; i < b.lists.length; i++) { 259 | let l: List = new List(board.id, i); 260 | l.setTitle(b.lists[i].name); 261 | 262 | for (let j: number = 0; j < b.cards.length; j++) { 263 | if (b.lists[i].id === b.cards[j].idList) { 264 | let c: Card = new Card(l, b.cards[j].name); 265 | 266 | c.description = b.cards[j].desc; 267 | c.due = b.cards[j].due ? new Date(b.cards[j].due).getTime() : null; 268 | c.isComplete = b.cards[j].dueComplete; 269 | 270 | l.cards.push(c); 271 | } 272 | } 273 | 274 | lists.push(l); 275 | } 276 | 277 | for (let i: number = 0; i < lists.length; i++) { 278 | cards = cards.concat(lists[i].cards); 279 | } 280 | 281 | await this.db.transaction('rw', this.db.boards, this.db.lists, this.db.cards, async (): Promise =>{ 282 | await Promise.all([ 283 | this.db.boards.add(board), 284 | this.db.lists.bulkAdd(lists), 285 | this.db.cards.bulkAdd(cards) 286 | ]); 287 | }); 288 | 289 | this.update(); 290 | 291 | return board.id; 292 | } 293 | 294 | public async moveCard(c: SortObject): Promise { 295 | let boardIndex: number = this.boards.findIndex(b => b.id === c.boardId); 296 | 297 | if (boardIndex > -1) { 298 | let listFromIndex: number = this.boards[boardIndex].lists.findIndex(l => l.id === c.from.list); 299 | let listToIndex: number = this.boards[boardIndex].lists.findIndex(l => l.id === c.to.list); 300 | 301 | if (listFromIndex > -1 && listToIndex > -1) { 302 | let cardIndexes: object = {}; 303 | let tmp: Card; 304 | 305 | if (c.from.list != c.to.list) { 306 | let fromListId: string; 307 | 308 | tmp = this.boards[boardIndex].lists[listFromIndex].cards.splice(c.from.index, 1)[0]; 309 | fromListId = tmp.listId; 310 | 311 | tmp.listId = this.boards[boardIndex].lists[listToIndex].id; 312 | tmp.index = c.to.index; 313 | 314 | for (let i: number = 0; i < this.boards[boardIndex].lists[listFromIndex].cards.length; i++) { 315 | this.boards[boardIndex].lists[listFromIndex].cards[i].index = i; 316 | 317 | cardIndexes[this.boards[boardIndex].lists[listFromIndex].cards[i].id] = i; 318 | } 319 | 320 | this.boards[boardIndex].lists[listToIndex].cards.splice(c.to.index, 0, tmp); 321 | 322 | for (let i: number = 0; i < this.boards[boardIndex].lists[listToIndex].cards.length; i++) { 323 | this.boards[boardIndex].lists[listToIndex].cards[i].index = i; 324 | 325 | cardIndexes[this.boards[boardIndex].lists[listToIndex].cards[i].id] = i; 326 | } 327 | } else { 328 | if (c.from.index != c.to.index) { 329 | tmp = this.boards[boardIndex].lists[listFromIndex].cards.splice(c.from.index, 1)[0]; 330 | tmp.index = c.to.index; 331 | 332 | this.boards[boardIndex].lists[listFromIndex].cards.splice(c.to.index, 0, tmp); 333 | 334 | for (let i: number = 0; i < this.boards[boardIndex].lists[listFromIndex].cards.length; i++) { 335 | this.boards[boardIndex].lists[listFromIndex].cards[i].index = i; 336 | 337 | cardIndexes[this.boards[boardIndex].lists[listFromIndex].cards[i].id] = i; 338 | } 339 | } 340 | } 341 | 342 | await this.updateCardIndexes(tmp, cardIndexes); 343 | } 344 | } 345 | } 346 | 347 | public async refresh(): Promise { 348 | await this.init(); 349 | } 350 | 351 | public setChannel(channel: BroadcastChannel): void { 352 | this.channel = channel; 353 | } 354 | 355 | public setSelectedCard(cardId: string | null): void { 356 | this.selectedCardId = cardId; 357 | } 358 | 359 | public async swapLists(boardId: string, l: SwapObject): Promise { 360 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 361 | 362 | if (boardIndex > -1) { 363 | this.boards[boardIndex].swapLists(l); 364 | 365 | await this.db.transaction('rw', this.db.lists, async (): Promise =>{ 366 | let [from, to]: List[] = await Promise.all([ 367 | this.db.lists.get({ boardId, index: l.from }), 368 | this.db.lists.get({ boardId, index: l.to }) 369 | ]); 370 | 371 | from.index = l.to; 372 | to.index = l.from; 373 | 374 | await this.db.lists.bulkPut([from, to]); 375 | }); 376 | } 377 | 378 | this.update(); 379 | } 380 | 381 | public async toggleBoardArchive(boardId: string): Promise { 382 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 383 | 384 | if (boardIndex > -1) { 385 | this.boards[boardIndex].toggleBoardArchive(); 386 | 387 | await this.db.boards.update(boardId, { 388 | isArchived: this.boards[boardIndex].isArchived 389 | }); 390 | } 391 | 392 | this.update(); 393 | } 394 | 395 | public async toggleBoardStar(boardId: string): Promise { 396 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 397 | 398 | if (boardIndex > -1) { 399 | this.boards[boardIndex].toggleBoardStar(); 400 | 401 | await this.db.boards.update(boardId, { 402 | isStarred: this.boards[boardIndex].isStarred 403 | }); 404 | } 405 | 406 | this.update(); 407 | } 408 | 409 | public async toggleTheme(): Promise { 410 | let theme: Theme = this.settings.theme === 'dark' ? 'light' : 'dark'; 411 | 412 | this.settings.theme = theme; 413 | 414 | await this.updateSettings(this.settings); 415 | this.update(); 416 | } 417 | 418 | public update(): void { 419 | this.channel.postMessage("update"); 420 | } 421 | 422 | public async updateBoardTitle(boardId: string, title: string): Promise { 423 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 424 | 425 | if (boardIndex > -1) { 426 | this.boards[boardIndex].setTitle(title); 427 | 428 | await this.db.boards.update(boardId, { 429 | title 430 | }); 431 | } 432 | 433 | this.update(); 434 | } 435 | 436 | public async updateCard(c: Card, property: string): Promise { 437 | for (let i: number = 0; i < this.boards.length; i++) { 438 | for (let j: number = 0; j < this.boards[i].lists.length; j++) { 439 | for (let k: number = 0; k < this.boards[i].lists[j].cards.length; k++) { 440 | if (this.boards[i].lists[j].cards[k].id === c.id) { 441 | this.boards[i].lists[j].cards[k] = c; 442 | } 443 | } 444 | } 445 | } 446 | 447 | await this.db.cards.update(c.id, { 448 | [property]: c[property] 449 | }); 450 | 451 | this.update(); 452 | } 453 | 454 | private async updateCardIndexes(c: Card, cardIndexes: object): Promise { 455 | await this.db.transaction('rw', this.db.cards, async (): Promise =>{ 456 | await this.db.cards.update(c.id, { 457 | listId: c.listId 458 | }); 459 | 460 | await this.db.cards.where("id").anyOf(Object.keys(cardIndexes)).modify(c => { 461 | c.index = cardIndexes[c.id]; 462 | }) 463 | }); 464 | 465 | this.update(); 466 | } 467 | 468 | public async updateLabels(boardId: string, labels: BoardLabel[]): Promise { 469 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 470 | 471 | if (boardIndex > -1) { 472 | this.boards[boardIndex].setLabels(labels); 473 | 474 | await this.db.boards.update(boardId, { 475 | labels 476 | }); 477 | } 478 | 479 | this.update(); 480 | } 481 | 482 | private async updateListIndexes(listIndexes: object): Promise { 483 | await this.db.lists.where("id").anyOf(Object.keys(listIndexes)).modify(l => { 484 | l.index = listIndexes[l.id]; 485 | }); 486 | } 487 | 488 | public async updateListTitle(boardId: string, listId: string, title: string): Promise { 489 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 490 | 491 | if (boardIndex > -1) { 492 | let listIndex: number = this.boards[boardIndex].lists.findIndex(l => l.id === listId); 493 | 494 | if (listIndex > -1) { 495 | this.boards[boardIndex].lists[listIndex].setTitle(title); 496 | 497 | await this.db.lists.update(listId, { 498 | title 499 | }); 500 | } 501 | } 502 | 503 | this.update(); 504 | } 505 | 506 | public async updateSettings(s: Settings): Promise { 507 | await this.db.settings.put(s); 508 | 509 | this.settings = s; 510 | 511 | this.update(); 512 | } 513 | 514 | public async updateViewTime(boardId: string): Promise { 515 | let boardIndex: number = this.boards.findIndex(b => b.id === boardId); 516 | 517 | if (boardIndex > -1) { 518 | let lastViewTime: number = new Date().getTime(); 519 | this.boards[boardIndex].setLastViewTime(lastViewTime); 520 | 521 | await this.db.boards.update(boardId, { 522 | lastViewTime 523 | }); 524 | } 525 | 526 | this.update(); 527 | } 528 | 529 | public async zenCards(): Promise { 530 | let d: Date = new Date(); 531 | d.setHours(23, 59, 59, 999); 532 | 533 | let t: any = getTimestamps(d); 534 | let allBoardsSelected: boolean = this.settings.selectedBoards.includes('all'); 535 | let listFilters: string[] = this.settings.listFilter.split(',').map(f => f.toLowerCase()); 536 | 537 | let lists: string[] = this.boards 538 | .filter(b => allBoardsSelected || this.settings.selectedBoards.includes(b.id)) 539 | .map(b => { 540 | return b.lists 541 | .filter(l => { 542 | return listFilters.length === 0 || listFilters.some(filter => l.title.toLowerCase().includes(filter)) 543 | }) 544 | .map(l => l.id) 545 | }) 546 | .reduce((acc, val) => acc.concat(val), []); 547 | 548 | let cards: Card[] = await this.db.cards 549 | .where('listId') 550 | .anyOf(lists) 551 | .filter((c: Card) => { 552 | if (this.settings.dueFilter === "all") { 553 | return true; 554 | } else if (this.settings.dueFilter === "overdue") { 555 | return c.isComplete && c.due && d.getTime() > c.due; 556 | } else if (this.settings.dueFilter === "tomorrow") { 557 | return !c.isComplete && c.due && c.due < t.tomorrow; 558 | } else if (this.settings.dueFilter === "week") { 559 | return !c.isComplete && c.due && c.due < t.week; 560 | } else if (this.settings.dueFilter === "month") { 561 | return !c.isComplete && c.due && c.due < t.month; 562 | } 563 | }) 564 | .toArray(); 565 | 566 | // sort by due date 567 | let cardsSortByDueDate = cards.filter(c => c.due && !c.isComplete); 568 | cardsSortByDueDate.sort((a: Card, b: Card) => { 569 | return a.due - b.due 570 | }); 571 | 572 | let cardsWithOutDueDate = cards.filter(c => c.due === null || c.due === undefined); 573 | 574 | // sort by priority 575 | let cardsSortByPriority = cardsWithOutDueDate.filter(c => c.priority in PRIORITY); 576 | cardsSortByPriority.sort((a: Card, b: Card) => { 577 | return a.priority - b.priority 578 | }); 579 | 580 | // sort by creation date 581 | let cardsSortByCreationDate = cardsWithOutDueDate.filter(c => !(c.priority in PRIORITY)); 582 | cardsSortByCreationDate.sort((a: Card, b: Card) => { 583 | return a.created - b.created 584 | }); 585 | 586 | cards = cardsSortByDueDate.concat(cardsSortByPriority, cardsSortByCreationDate); 587 | 588 | return cards.slice(0, this.settings.maxCards); 589 | } 590 | } -------------------------------------------------------------------------------- /src/routes/Board.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | {title} 63 | 64 | 65 |
66 | {#if boardIndex > -1} 67 |
68 |
69 | 70 |
71 | 72 |
73 | 74 |
75 | 76 |
77 | 78 |
79 |
80 | {:else} 81 |
82 |
83 | ¯\_(ツ)_/¯ 84 |
85 |

Board not found

86 | View all boards 87 |
88 | {/if} 89 |
-------------------------------------------------------------------------------- /src/routes/Card.svelte: -------------------------------------------------------------------------------- 1 | 58 | 59 | 60 | {title} 61 | 62 | 63 | 64 | 65 | 70 | 71 |
72 |
73 | {#if cardDetails} 74 | 75 | {:else} 76 |
77 |
78 | ¯\_(ツ)_/¯ 79 |
80 |

Card not found

81 | Go to home 82 |
83 | {/if} 84 |
85 |
-------------------------------------------------------------------------------- /src/routes/Home.svelte: -------------------------------------------------------------------------------- 1 | 106 | 107 | 108 | 109 | 110 | My Boards | nimbo 111 | 112 | 113 |
114 | 115 | 116 | 117 |
118 |
119 |

My Boards

120 | 127 |
128 | 129 |
130 | 143 |
144 | 150 | 156 |
157 | 158 |
159 |

160 | 161 | 162 | 163 | Starred 164 |

165 | 173 |
174 |
175 |

176 | 177 | 178 | 179 | Recently Viewed 180 |

181 | 189 |
190 |
191 | 229 |
230 |
-------------------------------------------------------------------------------- /src/routes/Zen.svelte: -------------------------------------------------------------------------------- 1 | 96 | 97 | 98 | 99 | 100 | Zen Mode | nimbo 101 | 102 | 103 | 108 | 109 |
110 |
111 |
112 |
113 |
114 |
115 | {#if cards.length} 116 | {#each cards as c} 117 |
handleCardClick(c.id)}> 118 | 119 |
120 | {/each} 121 | {:else} 122 |
123 | No cards to display. 124 |
125 | {/if} 126 |
127 |
128 |
129 | 135 |
136 | {#if showFilters} 137 |
138 |
139 |
140 | Boards 141 | 142 |
143 |
144 | Lists 145 | 151 |
152 | 162 | 171 |
172 |
173 | {/if} 174 |
175 | {#if $nimboStore.selectedCardId} 176 |
177 | 178 |
179 | {/if} 180 |
181 |
182 |
-------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import Board from "./Board.svelte"; 2 | import Card from "./Card.svelte"; 3 | import Home from "./Home.svelte"; 4 | import Zen from "./Zen.svelte"; 5 | 6 | export { 7 | Board, 8 | Card, 9 | Home, 10 | Zen 11 | } -------------------------------------------------------------------------------- /src/style/TailwindCSS.svelte: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Board, Card, List } from "./datastore/models"; 2 | 3 | export const BOARD_COLORS: string[] = [ 4 | "#DC7684", 5 | "#A0C1B8", 6 | "#2D7F9D", 7 | "#A1B6B4", 8 | "#717D84", 9 | "#B5A25D", 10 | "#6B436E", 11 | "#9A83A3", 12 | "#737088", 13 | "#A63D11", 14 | "#E97140", 15 | "#818773" 16 | ]; 17 | 18 | export type BoardColor = typeof BOARD_COLORS[number]; 19 | 20 | export type BoardLabel = { 21 | color: LABEL_COLOR; 22 | text: string; 23 | } 24 | 25 | export type CardDetails = { 26 | board: Board; 27 | list: List; 28 | card: Card; 29 | } 30 | 31 | export type ChecklistItem = { 32 | text: string; 33 | checked: boolean; 34 | } 35 | 36 | export type DueFilterOptions = "all" | "overdue" | "month" | "tomorrow" | "week"; 37 | 38 | export enum PRIORITY { 39 | P1, 40 | P2, 41 | P3, 42 | P4 43 | } 44 | 45 | export enum LABEL_COLOR { 46 | RED, 47 | ORANGE, 48 | YELLOW, 49 | GREEN, 50 | TEAL, 51 | BLUE, 52 | PURPLE 53 | } 54 | 55 | export enum RESULT_TYPE { 56 | BOARD, 57 | CARD 58 | } 59 | 60 | export type SearchObject = { 61 | type: RESULT_TYPE; 62 | text: string; 63 | path: string; 64 | } 65 | 66 | export type SortObject = { 67 | boardId: string, 68 | from: { 69 | list: string, 70 | index: number 71 | }, 72 | to: { 73 | list: string, 74 | index: number 75 | } 76 | } 77 | 78 | export type SwapObject = { 79 | from: number; 80 | to: number; 81 | } 82 | 83 | export type TimeEntry = { 84 | date: number; 85 | duration: number; 86 | } 87 | 88 | export type Theme = "dark" | "light"; -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from 'nanoid'; 2 | 3 | const d: Date = new Date(); 4 | const day: number = d.getDate(); 5 | const month: number = d.getMonth(); 6 | const year: number = d.getFullYear(); 7 | 8 | const beforeDate = (timestamp: number, when: string): boolean => { 9 | let tmpDate = new Date(); 10 | 11 | if (when === 'today') { 12 | return timestamp < tmpDate.getTime(); 13 | } else if (when === 'tomorrow') { 14 | tmpDate.setDate(tmpDate.getDate() + 1); 15 | return timestamp < tmpDate.getTime(); 16 | } 17 | 18 | return false; 19 | } 20 | 21 | const clickOutside = (node: Node, closeAction: Function): object => { 22 | const handleClick = (event: Event) => { 23 | let path = event.composedPath(); 24 | 25 | if (!path.includes(node)) { 26 | closeAction(); 27 | } 28 | }; 29 | 30 | setTimeout(() => { 31 | document.addEventListener("click", handleClick); 32 | }, 10); 33 | 34 | return { 35 | destroy() { 36 | document.removeEventListener("click", handleClick); 37 | }, 38 | }; 39 | }; 40 | 41 | const getTimestamps = (d: Date): object => { 42 | let tmp: Date = new Date(d); 43 | let timestamps: any = {}; 44 | 45 | tmp.setDate(d.getDate() + 1); 46 | timestamps.tomorrow = tmp.getTime(); 47 | 48 | tmp.setDate(d.getDate() + 7); 49 | timestamps.week = tmp.getTime(); 50 | 51 | tmp.setDate(d.getDate() + 30); 52 | timestamps.month = tmp.getTime(); 53 | 54 | return timestamps; 55 | } 56 | 57 | const formatDate = (timestamp: number): string => { 58 | d.setTime(timestamp); 59 | 60 | if ( 61 | d.getDate() === day && 62 | d.getMonth() === month && 63 | d.getFullYear() === year 64 | ) { 65 | return d.toLocaleString('default', { hour: 'numeric', minute: 'numeric' }); 66 | } else if (d.getFullYear() == year) { 67 | return d.toLocaleString('default', { month: 'short', day: 'numeric' }); 68 | } 69 | 70 | return d.toLocaleString('default', { year: 'numeric' }); 71 | } 72 | 73 | const formatForStopwatch = (seconds: number): string => { 74 | let hours = Math.floor(seconds / 3600) 75 | let minutes = Math.floor(seconds / 60) % 60 76 | seconds = seconds % 60 77 | 78 | return [hours,minutes,seconds] 79 | .map(v => v < 10 ? "0" + v : v) 80 | .filter((v,i) => v !== "00" || i > 0) 81 | .join(":") 82 | } 83 | 84 | const formatTime = (seconds: number): string => { 85 | let t: string = ""; 86 | 87 | let hrs: number = Math.floor(seconds / 3600); 88 | let min: number = Math.floor((seconds % 3600) / 60); 89 | let sec: number = seconds % 60; 90 | 91 | if (hrs) { 92 | t += `${hrs}h `; 93 | } 94 | 95 | if (min) { 96 | t += `${min}m `; 97 | } 98 | 99 | if (sec) { 100 | t += `${sec}s`; 101 | } 102 | 103 | return t; 104 | } 105 | 106 | const nanoid = customAlphabet('2346789ABCDEFGHJKLMNPQRTUVWXYZabcdefghijkmnpqrtwxyz', 10); 107 | 108 | export { 109 | beforeDate, 110 | clickOutside, 111 | getTimestamps, 112 | formatDate, 113 | formatForStopwatch, 114 | formatTime, 115 | nanoid 116 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: { 3 | mode: 'all', 4 | content: [ 5 | "./src/**/*.svelte" 6 | ], 7 | options: { 8 | defaultExtractor: (content) => { 9 | const regExp = new RegExp(/[A-Za-z0-9-_:\./]+/g) 10 | const matchedTokens = [] 11 | let match = regExp.exec(content) 12 | 13 | while (match) { 14 | if (match[0].startsWith('class:')) { 15 | matchedTokens.push(match[0].substring(6)) 16 | } else { 17 | matchedTokens.push(match[0]) 18 | } 19 | match = regExp.exec(content) 20 | } 21 | return matchedTokens 22 | } 23 | } 24 | }, 25 | theme: { 26 | customForms: theme => ({ 27 | default: { 28 | checkbox: { 29 | '&:focus': { 30 | boxShadow: '0 0 0 3px rgba(102, 126, 234, 0.5)', 31 | borderColor: theme('colors.indigo.400') 32 | } 33 | }, 34 | select: { 35 | '&:focus': { 36 | boxShadow: '0 0 0 3px rgba(102, 126, 234, 0.5)', 37 | borderColor: theme('colors.indigo.400') 38 | } 39 | } 40 | } 41 | }), 42 | extend: { 43 | colors: { 44 | "dark-100": "#4a4c4d", 45 | "dark-100-50": "rgba(74, 76, 77, 0.5)", 46 | "dark-200": "#343537", 47 | "dark-300": "#1d1f21", 48 | "dark-300-75": "rgba(29, 31, 33, 0.75)", 49 | "dark": "#a5a8a7", 50 | "light-100": "#e7e7e5", 51 | "light-100-50": "rgba(231, 231, 229, 0.5)", 52 | "light-200": "#dbdcdd", 53 | "light-300": "#fafbfd", 54 | "light": "#707070" 55 | }, 56 | spacing: { 57 | '116': '29rem', 58 | '160': '40rem' 59 | } 60 | }, 61 | truncate: { 62 | lines: { 63 | 2: '2', 64 | 3: '3' 65 | } 66 | } 67 | }, 68 | variants: { 69 | display: ({ after }) => after(['group-hover']), 70 | }, 71 | plugins: [ 72 | require('@tailwindcss/custom-forms'), 73 | require('tailwindcss-truncate-multiline')(), 74 | ], 75 | future: { 76 | removeDeprecatedGapUtilities: true, 77 | }, 78 | dark: 'class', 79 | experimental: { 80 | extendedSpacingScale: true, 81 | applyComplexClasses: true, 82 | darkModeVariant: true, 83 | purgeLayersByDefault: true 84 | }, 85 | } 86 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/svelte/tsconfig.json", 3 | 4 | "include": ["src/**/*"], 5 | "exclude": ["node_modules/*", "__sapper__/*", "public/*"], 6 | } --------------------------------------------------------------------------------