├── .nvmrc ├── .husky ├── .gitignore └── pre-commit ├── .prettierignore ├── styles ├── main.scss ├── _includes.scss ├── _variables.scss └── _bootstrap.scss ├── .babelrc ├── .huskyrc.json ├── .postcssrc.json ├── public ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png └── apple-touch-icon.png ├── next-env.d.ts ├── .lintstagedrc.json ├── features ├── board │ ├── components │ │ ├── RemoveButton.module.scss │ │ ├── BoardTag.module.scss │ │ ├── BoardCard.module.scss │ │ ├── BoardTag.tsx │ │ ├── ContentEditable.tsx │ │ ├── RemoveButton.tsx │ │ └── BoardCard.tsx │ ├── NewBoardPage.module.scss │ ├── _includes.scss │ ├── BoardSettingsModal.module.scss │ ├── ViewBoardPage.module.scss │ ├── NewBoardPage.tsx │ ├── BoardSettingsModal.tsx │ └── ViewBoardPage.tsx └── home │ └── HomePage.tsx ├── .eslintrc.js ├── utils ├── log.ts └── color.ts ├── .prettierrc.json ├── pages ├── index.tsx ├── new │ └── board.tsx ├── board │ └── [boardId].tsx └── _app.tsx ├── .editorconfig ├── .vscode └── settings.json ├── .stylelintrc.json ├── components ├── MainNavbar │ ├── style.module.scss │ └── index.tsx └── DefaultLayout │ └── index.tsx ├── services └── firebase │ ├── javelin.json │ ├── index.ts │ ├── auth.tsx │ └── board.ts ├── next.config.js ├── tsconfig.json ├── .github └── workflows │ └── test.yml ├── LICENSE ├── package.json ├── README.md └── .gitignore /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .husky 2 | .next 3 | .prettierignore 4 | -------------------------------------------------------------------------------- /styles/main.scss: -------------------------------------------------------------------------------- 1 | @import "includes"; 2 | @import "bootstrap"; 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["module:@tkesgar/sharo-babel"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "lint-staged" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.postcssrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "postcss-preset-env": {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkesgar/javelin/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkesgar/javelin/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkesgar/javelin/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tkesgar/javelin/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": ["prettier --write"], 3 | "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"] 4 | } 5 | -------------------------------------------------------------------------------- /features/board/components/RemoveButton.module.scss: -------------------------------------------------------------------------------- 1 | @import "../includes"; 2 | 3 | .RemoveButton { 4 | width: 28px; 5 | } 6 | -------------------------------------------------------------------------------- /features/board/NewBoardPage.module.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/includes"; 2 | 3 | .NewBoardCard { 4 | max-width: 32rem; 5 | } 6 | -------------------------------------------------------------------------------- /styles/_includes.scss: -------------------------------------------------------------------------------- 1 | @import "~bootstrap/scss/functions"; 2 | @import "~bootstrap/scss/mixins"; 3 | 4 | @import "variables"; 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: "sharo-scripts", 3 | // Workaround because Jest is not installed 4 | settings: { jest: { version: 26 } }, 5 | }; 6 | -------------------------------------------------------------------------------- /utils/log.ts: -------------------------------------------------------------------------------- 1 | import debug, { Debugger } from "debug"; 2 | 3 | export function createDebug(name: string): Debugger { 4 | return debug(`javelin:${name}`); 5 | } 6 | -------------------------------------------------------------------------------- /features/board/_includes.scss: -------------------------------------------------------------------------------- 1 | @import "../../styles/includes"; 2 | 3 | @mixin font-cursive() { 4 | font-family: $font-family-cursive; 5 | font-size: 16px; 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "overrides": [ 3 | { 4 | "files": "*.md", 5 | "options": { 6 | "proseWrap": "always" 7 | } 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /features/board/BoardSettingsModal.module.scss: -------------------------------------------------------------------------------- 1 | .LabelColorInput { 2 | border: none; 3 | background: transparent; 4 | padding: 0 1px; 5 | width: 21px; 6 | vertical-align: top; 7 | } 8 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import HomePage from "@/features/home/HomePage"; 2 | import * as React from "react"; 3 | 4 | export default function Index(): JSX.Element { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "stylelint.enable": true, 3 | "editor.formatOnPaste": true, 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pages/new/board.tsx: -------------------------------------------------------------------------------- 1 | import NewBoardPage from "@/features/board/NewBoardPage"; 2 | import * as React from "react"; 3 | 4 | export default function NewBoard(): JSX.Element { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /pages/board/[boardId].tsx: -------------------------------------------------------------------------------- 1 | import ViewBoardPage from "@/features/board/ViewBoardPage"; 2 | import * as React from "react"; 3 | 4 | export default function Board$Id(): JSX.Element { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-sharo"], 3 | "rules": { 4 | "selector-pseudo-class-no-unknown": [ 5 | true, 6 | { 7 | "ignorePseudoClasses": ["global"] 8 | } 9 | ] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /components/MainNavbar/style.module.scss: -------------------------------------------------------------------------------- 1 | .Avatar { 2 | width: 20px; 3 | height: 20px; 4 | } 5 | 6 | // stylelint-disable-next-line selector-pseudo-class-no-unknown 7 | .UserMenu :global(.dropdown-toggle) { 8 | display: flex; 9 | align-items: center; 10 | } 11 | -------------------------------------------------------------------------------- /services/firebase/javelin.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiKey": "AIzaSyDv24d_QIgRP22M8pC1__QIphrIRKiVhRU", 3 | "authDomain": "javelin-e855.firebaseapp.com", 4 | "projectId": "javelin-e855", 5 | "storageBucket": "javelin-e855.appspot.com", 6 | "messagingSenderId": "232808926685", 7 | "appId": "1:232808926685:web:dbc63bba7682777e2cba34" 8 | } 9 | -------------------------------------------------------------------------------- /components/DefaultLayout/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import MainNavbar from "../MainNavbar"; 3 | 4 | export default function DefaultLayout({ 5 | children, 6 | }: { 7 | children?: React.ReactNode; 8 | }): JSX.Element { 9 | return ( 10 | <> 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /features/board/components/BoardTag.module.scss: -------------------------------------------------------------------------------- 1 | @import "../includes"; 2 | 3 | .BoardTag { 4 | @include font-cursive(); 5 | @include transition(); 6 | font-size: 0.9rem; 7 | font-weight: 700; 8 | padding: 0.1rem 0.4rem; 9 | display: inline-block; 10 | border-radius: 100rem; 11 | } 12 | 13 | .PrependHash::before { 14 | content: "#"; 15 | } 16 | -------------------------------------------------------------------------------- /features/board/components/BoardCard.module.scss: -------------------------------------------------------------------------------- 1 | @import "../includes"; 2 | 3 | .Card { 4 | background-color: #ffefc1; 5 | } 6 | 7 | .CardContent { 8 | @include font-cursive(); 9 | min-height: 2rem; 10 | } 11 | 12 | .CardUserAvatar { 13 | width: 1.25rem; 14 | height: 1.25rem; 15 | 16 | // Prevent alt text from overflowing the img component if image fails to load. 17 | overflow: hidden; 18 | } 19 | -------------------------------------------------------------------------------- /utils/color.ts: -------------------------------------------------------------------------------- 1 | const YIQ_TEXT_DARK = "#212529"; 2 | const YIQ_TEXT_LIGHT = "#ffffff"; 3 | const YIQ_CONTRASTED_THRESHOLD = 150; 4 | 5 | export function colorYIQ( 6 | color: string, 7 | dark = YIQ_TEXT_DARK, 8 | light = YIQ_TEXT_LIGHT 9 | ): string { 10 | const [r, g, b] = color 11 | .slice(1) 12 | .match(/.{2}/g) 13 | .map((hex) => parseInt(hex, 16)); 14 | 15 | const yiq = (r * 299 + g * 587 + b * 114) / 1000; 16 | return yiq >= YIQ_CONTRASTED_THRESHOLD ? dark : light; 17 | } 18 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const childProcess = require("child_process"); 2 | const sharoNext = require("@tkesgar/sharo-next"); 3 | const package = require("./package.json"); 4 | 5 | const withSharo = sharoNext(); 6 | 7 | function getCommitHash() { 8 | return childProcess.execSync("git rev-parse HEAD").toString().trim(); 9 | } 10 | 11 | function getVersion() { 12 | return package.version; 13 | } 14 | 15 | module.exports = withSharo({ 16 | env: { 17 | COMMIT_HASH: getCommitHash(), 18 | VERSION: getVersion(), 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /services/firebase/index.ts: -------------------------------------------------------------------------------- 1 | import { createDebug } from "@/utils/log"; 2 | import firebase from "firebase/app"; 3 | import "firebase/auth"; 4 | import "firebase/firestore"; 5 | import JAVELIN_FIREBASE_CONFIG from "./javelin.json"; 6 | 7 | const debug = createDebug("firebase"); 8 | 9 | export function initializeApp(): void { 10 | const firebaseConfig = process.env.NEXT_PUBLIC_FIREBASE_CONFIG 11 | ? JSON.parse(process.env.NEXT_PUBLIC_FIREBASE_CONFIG) 12 | : JAVELIN_FIREBASE_CONFIG; 13 | 14 | firebase.initializeApp(firebaseConfig); 15 | debug("firebase initialized"); 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "downlevelIteration": true, 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./*"] 20 | } 21 | }, 22 | "exclude": ["node_modules"], 23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 24 | } 25 | -------------------------------------------------------------------------------- /styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $enable-responsive-font-sizes: true; 2 | 3 | $font-family-sans-serif: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", 4 | Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", 5 | "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 6 | 7 | $font-family-monospace: "Inconsolata", SFMono-Regular, Menlo, Monaco, Consolas, 8 | "Liberation Mono", "Courier New", monospace; 9 | 10 | $font-family-cursive: "Comic Neue", "Comic Sans", "Comic Sans MS", "Chalkboard", 11 | "ChalkboardSE-Regular", "Marker Felt", "Purisa", "URW Chancery L", cursive; 12 | 13 | @import "~bootswatch/dist/pulse/variables"; 14 | @import "~bootstrap/scss/variables"; 15 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - wisdom 7 | pull_request: 8 | branches: 9 | - wisdom 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-18.04 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/cache@v2.1.0 17 | with: 18 | path: | 19 | ~/.npm 20 | ~/.next/cache 21 | key: node-${{ hashFiles('**/package-lock.json') }} 22 | restore-keys: | 23 | node- 24 | - name: Install dependencies 25 | run: npm ci 26 | - name: Execute linters 27 | run: | 28 | npm run lint:scripts 29 | npm run lint:styles 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ted Kesgar 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 | -------------------------------------------------------------------------------- /features/board/ViewBoardPage.module.scss: -------------------------------------------------------------------------------- 1 | @import "includes"; 2 | 3 | @mixin line-clamp($amount: 1) { 4 | // https://css-tricks.com/almanac/properties/l/line-clamp/ 5 | display: -webkit-box; 6 | -webkit-line-clamp: $amount; 7 | -webkit-box-orient: vertical; 8 | overflow: hidden; 9 | } 10 | 11 | .Viewport { 12 | overflow-x: auto; 13 | } 14 | 15 | .Container { 16 | $section-min-width: 15rem; 17 | $section-max-width: 20rem; 18 | $section-spacing: $grid-gutter-width; 19 | 20 | @mixin generate-width($column-count) { 21 | width: 100%; 22 | min-width: calc( 23 | #{$column-count * $section-min-width} + #{($column-count - 1) * 24 | $section-spacing} 25 | ); 26 | max-width: calc( 27 | #{$column-count * $section-max-width} + #{($column-count - 1) * 28 | $section-spacing} 29 | ); 30 | } 31 | 32 | @for $i from 1 through 4 { 33 | &.SectionCount#{$i} { 34 | @include generate-width($i); 35 | } 36 | } 37 | } 38 | 39 | .SectionTitle { 40 | // https://css-tricks.com/almanac/properties/l/line-clamp/ 41 | @include line-clamp(1); 42 | } 43 | 44 | .BoardHeader { 45 | @include media-breakpoint-up(md) { 46 | position: sticky; 47 | top: 0; 48 | z-index: 1; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "javelin", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint:scripts": "eslint . --ext js,ts,tsx && tsc --noEmit", 10 | "lint:styles": "stylelint **/*.scss", 11 | "format": "prettier --write .", 12 | "postinstall": "husky install" 13 | }, 14 | "dependencies": { 15 | "@tkesgar/sharo-babel": "^3.0.0-beta.2", 16 | "@tkesgar/sharo-next": "^3.0.0-beta.2", 17 | "bootstrap": "^4.6.0", 18 | "bootswatch": "^4.6.0", 19 | "classnames": "^2.2.6", 20 | "dayjs": "^1.10.4", 21 | "debug": "^4.3.1", 22 | "dompurify": "^2.2.6", 23 | "firebase": "^8.2.10", 24 | "lodash": "^4.17.21", 25 | "next": "^12.1.0", 26 | "postcss-preset-env": "^6.7.0", 27 | "react": "^17.0.1", 28 | "react-bootstrap": "^1.5.1", 29 | "react-dom": "^17.0.1", 30 | "react-feather": "^2.0.9", 31 | "sass": "^1.32.8", 32 | "swr": "^0.4.2", 33 | "typescript": "^4.2.3" 34 | }, 35 | "devDependencies": { 36 | "@types/debug": "^4.1.5", 37 | "@types/dompurify": "^2.2.1", 38 | "@types/lodash": "^4.14.168", 39 | "@types/node": "^14.14.31", 40 | "@types/react": "^17.0.2", 41 | "eslint": "^7.21.0", 42 | "eslint-config-sharo-scripts": "^3.0.0-beta.2", 43 | "husky": "^5.1.3", 44 | "lint-staged": "^10.5.4", 45 | "prettier": "^2.2.1", 46 | "stylelint": "^13.11.0", 47 | "stylelint-config-sharo": "^3.0.0-beta.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /features/board/components/BoardTag.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import style from "./BoardTag.module.scss"; 3 | import { colorYIQ } from "@/utils/color"; 4 | import classnames from "classnames"; 5 | 6 | const NO_COLOR_BORDER = "#00000040"; 7 | 8 | type BoardTagProps = React.ComponentPropsWithRef<"span"> & { 9 | color?: string; 10 | hash?: boolean; 11 | }; 12 | 13 | export default function BoardTag({ 14 | color, 15 | hash = false, 16 | children, 17 | className, 18 | style: styleProp, 19 | ...restProps 20 | }: BoardTagProps): JSX.Element { 21 | return ( 22 | 39 | {children} 40 | 41 | ); 42 | } 43 | 44 | export function colorizeLabels( 45 | text: string, 46 | labelColors: Record 47 | ): string { 48 | return text.replace(/#(\w+)/g, (match, p1) => { 49 | const color = labelColors[p1]; 50 | return `#${p1}`; 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /features/board/components/ContentEditable.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | type ContentEditableProps = React.ComponentPropsWithoutRef<"div"> & { 4 | initialText?: string; 5 | placeholder?: string; 6 | onContentChange?: (text: string) => void; 7 | transformHTML?: (text: string) => string; 8 | }; 9 | 10 | export default function ContentEditable({ 11 | initialText = "", 12 | onContentChange, 13 | transformHTML, 14 | ...restProps 15 | }: ContentEditableProps): JSX.Element { 16 | const [text, setText] = React.useState(initialText); 17 | const divRef = React.useRef(); 18 | 19 | const processHTML = React.useCallback( 20 | (text: string) => { 21 | let result = text; 22 | 23 | if (transformHTML) { 24 | result = transformHTML(text); 25 | } 26 | 27 | return result; 28 | }, 29 | [transformHTML] 30 | ); 31 | 32 | React.useEffect(() => { 33 | if (!divRef.current) { 34 | return; 35 | } 36 | 37 | divRef.current.innerHTML = processHTML(initialText); 38 | }, [initialText, processHTML]); 39 | 40 | React.useEffect(() => { 41 | if (!divRef.current) { 42 | return; 43 | } 44 | 45 | divRef.current.innerHTML = processHTML(text); 46 | }, [text, processHTML]); 47 | 48 | return ( 49 |
{ 54 | if (onContentChange) { 55 | const currentText = divRef.current.innerText; 56 | if (currentText !== text) { 57 | setText(currentText); 58 | onContentChange(currentText); 59 | } 60 | } 61 | }} 62 | /> 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /styles/_bootstrap.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v4.6.0 (https://getbootstrap.com/) 3 | * Copyright 2011-2021 The Bootstrap Authors 4 | * Copyright 2011-2021 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 6 | */ 7 | 8 | @import "~bootstrap/scss/root"; 9 | @import "~bootstrap/scss/reboot"; 10 | @import "~bootstrap/scss/type"; 11 | @import "~bootstrap/scss/images"; 12 | @import "~bootstrap/scss/code"; 13 | @import "~bootstrap/scss/grid"; 14 | @import "~bootstrap/scss/tables"; 15 | @import "~bootstrap/scss/forms"; 16 | @import "~bootstrap/scss/buttons"; 17 | @import "~bootstrap/scss/transitions"; 18 | @import "~bootstrap/scss/dropdown"; 19 | @import "~bootstrap/scss/button-group"; 20 | @import "~bootstrap/scss/input-group"; 21 | @import "~bootstrap/scss/custom-forms"; 22 | @import "~bootstrap/scss/nav"; 23 | @import "~bootstrap/scss/navbar"; 24 | @import "~bootstrap/scss/card"; 25 | @import "~bootstrap/scss/breadcrumb"; 26 | @import "~bootstrap/scss/pagination"; 27 | @import "~bootstrap/scss/badge"; 28 | @import "~bootstrap/scss/jumbotron"; 29 | @import "~bootstrap/scss/alert"; 30 | @import "~bootstrap/scss/progress"; 31 | @import "~bootstrap/scss/media"; 32 | @import "~bootstrap/scss/list-group"; 33 | @import "~bootstrap/scss/close"; 34 | @import "~bootstrap/scss/toasts"; 35 | @import "~bootstrap/scss/modal"; 36 | @import "~bootstrap/scss/tooltip"; 37 | @import "~bootstrap/scss/popover"; 38 | @import "~bootstrap/scss/carousel"; 39 | @import "~bootstrap/scss/spinners"; 40 | @import "~bootstrap/scss/utilities"; 41 | @import "~bootstrap/scss/print"; 42 | 43 | @import "~bootswatch/dist/pulse/bootswatch"; 44 | 45 | // Add backdrop-filter to modals 46 | .modal { 47 | backdrop-filter: blur(4px); 48 | } 49 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Head from "next/head"; 3 | import { AppProps } from "next/app"; 4 | import "@/styles/main.scss"; 5 | import { AuthProvider } from "@/services/firebase/auth"; 6 | import { initializeApp } from "@/services/firebase"; 7 | import * as day from "dayjs"; 8 | import RelativeTime from "dayjs/plugin/relativeTime"; 9 | 10 | day.extend(RelativeTime); 11 | 12 | if (typeof window !== "undefined") { 13 | initializeApp(); 14 | } 15 | 16 | export default function App({ Component, pageProps }: AppProps): JSX.Element { 17 | return ( 18 | <> 19 | 20 | 24 | 25 | 29 | 30 | {/* Created using https://favicon.io/favicon-generator/ */} 31 | 36 | 42 | 48 | 49 | javelin 50 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /services/firebase/auth.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import firebase from "firebase/app"; 3 | import { createDebug } from "@/utils/log"; 4 | 5 | export interface Auth { 6 | uid: string; 7 | email: string; 8 | displayName: string; 9 | photoURL: string; 10 | } 11 | 12 | const debug = createDebug("firebase-auth"); 13 | 14 | const AuthContext = React.createContext(false); 15 | 16 | function createAuth(user: firebase.User) { 17 | return { 18 | uid: user.uid, 19 | email: user.email, 20 | displayName: user.displayName, 21 | photoURL: user.photoURL, 22 | }; 23 | } 24 | 25 | export function AuthProvider({ 26 | children, 27 | }: { 28 | children: React.ReactNode; 29 | }): JSX.Element { 30 | const [auth, setAuth] = React.useState(false); 31 | 32 | React.useEffect(() => { 33 | const unsubscribe = firebase.auth().onAuthStateChanged((user) => { 34 | setAuth(user ? createAuth(user) : null); 35 | debug("auth state changed"); 36 | }); 37 | 38 | return () => { 39 | unsubscribe(); 40 | setAuth(false); 41 | }; 42 | }, []); 43 | 44 | return {children}; 45 | } 46 | 47 | export function useAuth(): false | Auth { 48 | return React.useContext(AuthContext); 49 | } 50 | 51 | export function useAuthorize( 52 | authorize = (auth: Auth) => Boolean(auth) 53 | ): boolean { 54 | const auth = useAuth(); 55 | 56 | return auth === false ? null : Boolean(auth) && authorize(auth); 57 | } 58 | 59 | export async function signIn(): Promise { 60 | const provider = new firebase.auth.GoogleAuthProvider(); 61 | provider.addScope("email"); 62 | 63 | const credential = await firebase.auth().signInWithPopup(provider); 64 | debug("sign in"); 65 | 66 | return createAuth(credential.user); 67 | } 68 | 69 | export async function signOut(): Promise { 70 | await firebase.auth().signOut(); 71 | debug("sign out"); 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # javelin 2 | 3 | [![ジャベリンかわいい](https://cdn.donmai.us/original/a5/35/__javelin_azur_lane_drawn_by_moupii_hitsuji_no_ki__a535453fe8057a3bb34797589317856f.png)](https://www.pixiv.net/en/artworks/73661871) 4 | 5 | > I've gotten closer to you now, Commander. Hehehe. But I'm gonna have to work 6 | > harder~ 7 | 8 | javelin is an app where people can arrange notes in a number of columns. It is 9 | built with [Next.js][next] and uses [Firebase][firebase] to store data. 10 | 11 | A public instance is available here: https://javelin.vercel.app. 12 | 13 | ## Usage 14 | 15 | ### Requirements 16 | 17 | - Node.js 14 18 | - A Firebase project to use (free tier is enough) 19 | 20 | ### Installation 21 | 22 | Clone this repository, then install the dependencies: 23 | 24 | ```bash 25 | git clone https://github.com/tkesgar/javelin 26 | cd javelin 27 | npm install 28 | ``` 29 | 30 | Get the Firebase project configuration and add it in `.env` as 31 | `NEXT_PUBLIC_FIREBASE_CONFIG`. Otherwise it will uses the public javelin 32 | Firebase instance. 33 | 34 | ``` 35 | NEXT_PUBLIC_FIREBASE_CONFIG="{ 36 | "apiKey":"firebase-api-key","authDomain": "firebase-auth-domain.firebaseapp.com", 37 | "databaseURL": "https://firebase-database-url.firebaseio.com", 38 | "projectId": "firebase-project-id", 39 | "storageBucket": "firebase-storage-bucket.appspot.com", 40 | "messagingSenderId": "firebase-messaging-sender-id", 41 | "appId": "firebase-app-id" 42 | }" 43 | ``` 44 | 45 | ### Development 46 | 47 | ```bash 48 | npm run dev 49 | ``` 50 | 51 | ### Deployment 52 | 53 | ``` 54 | npm run build 55 | npm start 56 | ``` 57 | 58 | ## Contributing 59 | 60 | Feel free to submit [issues] and create [pull requests][pulls]. 61 | 62 | ## License 63 | 64 | Licensed under MIT License. 65 | 66 | 67 | [firebase]: https://firebase.google.com/ 68 | [issues]: https://github.com/tkesgar/javelin/issues 69 | [pulls]: https://github.com/tkesgar/javelin/pulls 70 | 71 | -------------------------------------------------------------------------------- /features/board/components/RemoveButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Button } from "react-bootstrap"; 3 | import { AlertCircle, Trash2 } from "react-feather"; 4 | import style from "./RemoveButton.module.scss"; 5 | import classnames from "classnames"; 6 | 7 | type RemoveButtonProps = React.ComponentPropsWithRef 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /components/MainNavbar/index.tsx: -------------------------------------------------------------------------------- 1 | import { signIn, signOut, useAuth } from "@/services/firebase/auth"; 2 | import * as React from "react"; 3 | import { Button, Nav, Navbar, NavDropdown } from "react-bootstrap"; 4 | import style from "./style.module.scss"; 5 | import classnames from "classnames"; 6 | import Link from "next/link"; 7 | 8 | export default function MainNavbar(): JSX.Element { 9 | const auth = useAuth(); 10 | 11 | return ( 12 | 13 | 27 | 28 | 29 | javelinWIP 30 | 31 | 32 | 33 | {auth === false ? null : auth ? ( 34 | 61 | ) : ( 62 | 73 | )} 74 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node.gitignore 2 | # https://github.com/github/gitignore/blob/master/Node.gitignore 3 | # ============================================================================== 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # Diagnostic reports (https://nodejs.org/api/report.html) 14 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | *.lcov 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # Snowpack dependency directory (https://snowpack.dev/) 49 | web_modules/ 50 | 51 | # TypeScript cache 52 | *.tsbuildinfo 53 | 54 | # Optional npm cache directory 55 | .npm 56 | 57 | # Optional eslint cache 58 | .eslintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variables file 76 | .env 77 | .env.test 78 | 79 | # parcel-bundler cache (https://parceljs.org/) 80 | .cache 81 | .parcel-cache 82 | 83 | # Next.js build output 84 | .next 85 | 86 | # Nuxt.js build / generate output 87 | .nuxt 88 | dist 89 | 90 | # Gatsby files 91 | .cache/ 92 | # Comment in the public line in if your project uses Gatsby and not Next.js 93 | # https://nextjs.org/blog/next-9-1#public-directory-support 94 | # public 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | # TernJS port file 109 | .tern-port 110 | 111 | # Stores VSCode versions used for testing VSCode extensions 112 | .vscode-test 113 | 114 | # yarn v2 115 | 116 | .yarn/cache 117 | .yarn/unplugged 118 | .yarn/build-state.yml 119 | .pnp.* 120 | -------------------------------------------------------------------------------- /features/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import DefaultLayout from "@/components/DefaultLayout"; 2 | import Link from "next/link"; 3 | import * as React from "react"; 4 | import { Button, Card, Container, Spinner } from "react-bootstrap"; 5 | import { Board, getMyBoards } from "@/services/firebase/board"; 6 | import useSWR from "swr"; 7 | import { Auth, signIn, useAuth } from "@/services/firebase/auth"; 8 | import { useRouter } from "next/router"; 9 | 10 | export default function HomePage(): JSX.Element { 11 | const auth = useAuth(); 12 | const router = useRouter(); 13 | const { data: myBoards, error: myBoardsError } = useSWR( 14 | (auth as Auth)?.uid, 15 | getMyBoards 16 | ); 17 | 18 | React.useEffect(() => { 19 | if (!myBoardsError) { 20 | return; 21 | } 22 | 23 | console.error(myBoardsError); 24 | }, [myBoardsError]); 25 | 26 | return ( 27 | 28 | {auth === false ? ( 29 | 30 | ) : auth ? ( 31 | 32 |

My boards

33 | {(myBoards || null) && 34 | (myBoards.length === 0 ? ( 35 | <> 36 |

37 | You currently do not have any boards. 38 |

39 | 40 | 41 | 42 | 43 | ) : ( 44 | <> 45 | 46 | 49 | 50 | {myBoards.map((board) => ( 51 | { 56 | router.push(`/board/${board.id}`); 57 | }} 58 | > 59 | 60 | {board.title} 61 | {board.description && ( 62 |

{board.description}

63 | )} 64 |
65 |
66 | ))} 67 | 68 | ))} 69 |
70 | ) : ( 71 | 72 |

You are not logged in.

73 |

You need to log in to create boards.

74 | 85 |
86 | )} 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /features/board/NewBoardPage.tsx: -------------------------------------------------------------------------------- 1 | import DefaultLayout from "@/components/DefaultLayout"; 2 | import { useAuth } from "@/services/firebase/auth"; 3 | import { createBoard } from "@/services/firebase/board"; 4 | import { useRouter } from "next/router"; 5 | import * as React from "react"; 6 | import { Button, Card, Container, Form } from "react-bootstrap"; 7 | import style from "./NewBoardPage.module.scss"; 8 | import classnames from "classnames"; 9 | 10 | const MAX_SECTION = 4; 11 | 12 | function range(count: number): number[] { 13 | return Array(count) 14 | .fill(null) 15 | .map((e, i) => i + 1); 16 | } 17 | 18 | export default function NewBoardPage(): JSX.Element { 19 | const auth = useAuth(); 20 | const router = useRouter(); 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | Create a new board 28 | { 30 | if (!auth) { 31 | throw new Error(`User is not authenticated`); 32 | } 33 | 34 | (async () => { 35 | const boardId = await createBoard({ 36 | userId: auth.uid, 37 | ...value, 38 | }); 39 | await router.push(`/board/${boardId}`); 40 | })().catch((error) => alert(error.message)); 41 | }} 42 | /> 43 | 44 | 45 | 46 | 47 | ); 48 | } 49 | 50 | interface NewBoardFormProps { 51 | onSubmit: (value: { 52 | title: string; 53 | description: string; 54 | sectionTitles: string[]; 55 | }) => void; 56 | } 57 | 58 | function NewBoardForm({ onSubmit }: NewBoardFormProps): JSX.Element { 59 | const [inputTitle, setInputTitle] = React.useState(""); 60 | const [inputDescription, setInputDescription] = React.useState(""); 61 | const [numberSections, setNumberSections] = React.useState(1); 62 | const [inputSectionTitles, setInputSectionTitles] = React.useState( 63 | Array(MAX_SECTION).fill("") 64 | ); 65 | 66 | return ( 67 |
{ 69 | evt.preventDefault(); 70 | 71 | onSubmit({ 72 | title: inputTitle.trim(), 73 | description: inputDescription.trim() || null, 74 | sectionTitles: inputSectionTitles 75 | .slice(0, numberSections) 76 | .map((input) => input.trim()), 77 | }); 78 | }} 79 | > 80 | 81 | Title 82 | setInputTitle(evt.target.value)} 89 | /> 90 | 91 | 92 | Description 93 | setInputDescription(evt.target.value)} 100 | /> 101 | 102 | 103 | Number of sections 104 | setNumberSections(Number(evt.target.value) || 1)} 112 | style={{ maxWidth: "8rem" }} 113 | /> 114 | 115 | {range(numberSections).map((index) => ( 116 | 121 | Section title {index} 122 | 129 | setInputSectionTitles( 130 | inputSectionTitles.map((title, i) => 131 | i === index - 1 ? evt.target.value : title 132 | ) 133 | ) 134 | } 135 | /> 136 | 137 | ))} 138 | 141 |
142 | ); 143 | } 144 | -------------------------------------------------------------------------------- /features/board/components/BoardCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, User } from "@/services/firebase/board"; 2 | import * as React from "react"; 3 | import { Button } from "react-bootstrap"; 4 | import { ChevronLeft, ChevronRight } from "react-feather"; 5 | import style from "./BoardCard.module.scss"; 6 | import classnames from "classnames"; 7 | import day from "dayjs"; 8 | import ContentEditable from "./ContentEditable"; 9 | import RemoveButton from "./RemoveButton"; 10 | import BoardTag, { colorizeLabels } from "./BoardTag"; 11 | 12 | type BoardCardProps = React.ComponentPropsWithRef<"div"> & { 13 | card: Card; 14 | user?: User; 15 | editable?: boolean; 16 | processTags?: boolean; 17 | canMoveLeft?: boolean; 18 | canMoveRight?: boolean; 19 | showCreator?: boolean; 20 | showTimestamp?: boolean; 21 | showRemove?: boolean; 22 | labelColors?: Record; 23 | tags?: string[]; 24 | onMove?: (direction: "left" | "right") => void; 25 | onTextUpdate?: (text: string) => void; 26 | onRemove?: () => void; 27 | }; 28 | 29 | export default function BoardCard({ 30 | card, 31 | user, 32 | editable = false, 33 | processTags = false, 34 | canMoveLeft = false, 35 | canMoveRight = false, 36 | showCreator = false, 37 | showTimestamp = false, 38 | showRemove = false, 39 | labelColors = {}, 40 | tags = [], 41 | onMove, 42 | onTextUpdate, 43 | onRemove, 44 | className, 45 | ...restProps 46 | }: BoardCardProps): JSX.Element { 47 | const [confirmDelete, setConfirmDelete] = React.useState(false); 48 | 49 | const transformHTMLCallback = React.useCallback( 50 | (text: string) => { 51 | if (!processTags) { 52 | return text; 53 | } 54 | 55 | return colorizeLabels(text, labelColors); 56 | }, 57 | [labelColors, processTags] 58 | ); 59 | 60 | React.useEffect(() => { 61 | if (!confirmDelete) { 62 | return; 63 | } 64 | 65 | const timeout = setTimeout(() => { 66 | setConfirmDelete(false); 67 | }, 2000); 68 | 69 | return () => clearTimeout(timeout); 70 | }, [confirmDelete]); 71 | 72 | const cardTime = formatTimestamp(card.timeCreated); 73 | 74 | return ( 75 |
76 | {tags.length > 0 ? ( 77 |
78 | {tags.map((tag) => ( 79 | 80 | {tag} 81 | 82 | ))} 83 |
84 | ) : null} 85 |
86 | 98 |
99 | {editable ? ( 100 | { 105 | if (onTextUpdate) { 106 | onTextUpdate(text); 107 | } 108 | }} 109 | /> 110 | ) : ( 111 |
117 | )} 118 |
119 | 131 |
132 |
133 |
134 | {user && showCreator ? ( 135 | {user.displayName} 144 | ) : null} 145 | {showTimestamp ? {cardTime} : null} 146 |
147 | {showRemove ? : null} 148 |
149 |
150 | ); 151 | } 152 | 153 | function formatTimestamp(ts: number): string { 154 | const minutes = day().diff(ts, "m"); 155 | if (minutes < 24 * 60) { 156 | return `${minutes}m ago`; 157 | } 158 | 159 | const days = day().diff(ts, "d"); 160 | return `${days}d ago`; 161 | } 162 | -------------------------------------------------------------------------------- /features/board/BoardSettingsModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Board, 3 | DEFAULT_TAG_COLOR, 4 | updateBoard, 5 | } from "@/services/firebase/board"; 6 | import * as React from "react"; 7 | import { Button, Form, Modal } from "react-bootstrap"; 8 | import style from "./BoardSettingsModal.module.scss"; 9 | import classnames from "classnames"; 10 | import debounce from "lodash/debounce"; 11 | import ContentEditable from "./components/ContentEditable"; 12 | import RemoveButton from "./components/RemoveButton"; 13 | import BoardTag from "./components/BoardTag"; 14 | 15 | const DEFAULT_MARK_STALE_MINUTES = 24 * 60; 16 | 17 | type BoardSettingsProps = React.ComponentPropsWithRef & { 18 | board: Board; 19 | }; 20 | 21 | export default function BoardSettingsModal({ 22 | board, 23 | onHide, 24 | ...restProps 25 | }: BoardSettingsProps): JSX.Element { 26 | return ( 27 | 28 | 29 | 30 | Settings 31 | 32 | 33 | 34 |

Board information

35 | 36 | 37 |

Board configuration

38 |
39 | 40 |
41 | 42 |

Label colors

43 |
44 | 45 |
46 |
47 | 48 | 51 | 52 |
53 | ); 54 | } 55 | 56 | function BoardInfoForm({ board }: { board: Board }): JSX.Element { 57 | const [inputTitle, setInputTitle] = React.useState(board.title); 58 | const [inputDescription, setInputDescription] = React.useState( 59 | board.description || "" 60 | ); 61 | 62 | React.useEffect(() => { 63 | setInputTitle(board.title); 64 | setInputDescription(board.description || ""); 65 | }, [board]); 66 | 67 | return ( 68 |
{ 70 | evt.preventDefault(); 71 | 72 | updateBoard({ 73 | id: board.id, 74 | title: inputTitle.trim(), 75 | description: inputDescription.trim() || null, 76 | }).catch((error) => alert(error.message)); 77 | }} 78 | className="mb-4" 79 | > 80 | 81 | Title 82 | setInputTitle(evt.target.value)} 89 | /> 90 | 91 | 92 | Description 93 | setInputDescription(evt.target.value || "")} 100 | /> 101 | 102 | 105 |
106 | ); 107 | } 108 | 109 | function BoardConfig({ board }: { board: Board }): JSX.Element { 110 | const [config, setConfig] = React.useState({ 111 | ...board.config, 112 | }); 113 | 114 | React.useEffect(() => { 115 | setConfig({ ...board.config }); 116 | }, [board]); 117 | 118 | function updateConfig(name: keyof Board["config"], value: unknown): void { 119 | setConfig((currentConfig) => ({ 120 | ...currentConfig, 121 | [name]: value, 122 | })); 123 | updateBoard({ 124 | id: board.id, 125 | config: { 126 | ...board.config, 127 | [name]: value, 128 | }, 129 | }).catch((error) => alert(error.message)); 130 | } 131 | 132 | const staleTagColor = 133 | board.labels.find((label) => label.key === "stale")?.color || 134 | DEFAULT_TAG_COLOR; 135 | 136 | return ( 137 | <> 138 | 139 | { 145 | const value = evt.target.checked; 146 | updateConfig("showCardCreator", value); 147 | }} 148 | /> 149 | 150 | 151 | { 157 | const value = evt.target.checked; 158 | updateConfig("showTimestamp", value); 159 | }} 160 | /> 161 | 162 | 163 | { 169 | const value = evt.target.checked; 170 | updateConfig("removeCardOnlyOwner", value); 171 | }} 172 | /> 173 | 174 | 175 | 181 | Mark old cards with{" "} 182 | 183 | stale 184 | {" "} 185 | tag 186 | 187 | } 188 | checked={config.markStaleMinutes > 0} 189 | onChange={(evt) => { 190 | const checked = evt.target.checked; 191 | updateConfig( 192 | "markStaleMinutes", 193 | checked ? DEFAULT_MARK_STALE_MINUTES : 0 194 | ); 195 | }} 196 | /> 197 | {config.markStaleMinutes > 0 ? ( 198 |
199 | 203 | Stale cards age: 204 | 205 | { 212 | const valueStr = evt.target.value; 213 | 214 | const value = parseInt(valueStr, 10); 215 | if (!value || value <= 0) { 216 | return; 217 | } 218 | 219 | updateConfig("markStaleMinutes", value); 220 | }} 221 | /> 222 |
223 | ) : null} 224 |
225 | 226 | ); 227 | } 228 | 229 | function BoardLabels({ board }: { board: Board }): JSX.Element { 230 | const [labels, setLabels] = React.useState([ 231 | ...board.labels, 232 | ]); 233 | 234 | React.useEffect(() => { 235 | setLabels([...board.labels]); 236 | }, [board]); 237 | 238 | function updateLabels( 239 | updateFn: (currentLabels: Board["labels"]) => Board["labels"] 240 | ): void { 241 | setLabels(updateFn); 242 | updateBoard({ 243 | id: board.id, 244 | labels: updateFn(board.labels), 245 | }).catch((error) => alert(error.message)); 246 | } 247 | 248 | const updateLabelColor = debounce((index: number, color: string) => { 249 | updateLabels((currentLabels) => 250 | currentLabels.map((label, i) => 251 | i === index ? { ...label, color } : label 252 | ) 253 | ); 254 | }, 500); 255 | 256 | return ( 257 | <> 258 |
259 | {labels.length === 0 ? ( 260 |
This board currently has no labels.
261 | ) : ( 262 |
    263 | {labels.map((label, index) => ( 264 |
  • 265 |
    266 |
    267 | 268 | { 272 | const text = inputText.replace(/[^\w]/g, "_"); 273 | updateLabels((currentLabels) => 274 | currentLabels.map((l, i) => 275 | i === index ? { key: text, color: l.color } : l 276 | ) 277 | ); 278 | }} 279 | /> 280 | 281 |
    282 | { 287 | const color = evt.target.value; 288 | updateLabelColor(index, color); 289 | }} 290 | /> 291 | { 293 | updateLabels((currentLabels) => 294 | currentLabels.filter((l, i) => i !== index) 295 | ); 296 | }} 297 | /> 298 | {(() => { 299 | let message: string = null; 300 | 301 | if ( 302 | labels.findIndex((l) => l.key === label.key) !== index 303 | ) { 304 | message = "Duplicate labels will be ignored"; 305 | } 306 | 307 | if (message) { 308 | return message ? ( 309 | 310 | {message} 311 | 312 | ) : null; 313 | } 314 | })()} 315 |
    316 |
  • 317 | ))} 318 |
319 | )} 320 |
321 | 344 | 345 | ); 346 | } 347 | -------------------------------------------------------------------------------- /features/board/ViewBoardPage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createCard, 3 | removeCard, 4 | updateCard, 5 | moveCard, 6 | useBoard, 7 | useBoardCards, 8 | useBoardSections, 9 | useBoardUsers, 10 | DEFAULT_TAG_COLOR, 11 | Board, 12 | Card, 13 | } from "@/services/firebase/board"; 14 | import { useRouter } from "next/router"; 15 | import * as React from "react"; 16 | import { Button, Col, Container, Row, Spinner } from "react-bootstrap"; 17 | import { Plus, Settings } from "react-feather"; 18 | import style from "./ViewBoardPage.module.scss"; 19 | import classnames from "classnames"; 20 | import MainNavbar from "@/components/MainNavbar"; 21 | import { useAuth } from "@/services/firebase/auth"; 22 | import BoardCard from "./components/BoardCard"; 23 | import BoardSettingsModal from "./BoardSettingsModal"; 24 | import dayjs from "dayjs"; 25 | import BoardTag from "./components/BoardTag"; 26 | 27 | const FILTER_STORAGE_KEY = (boardId: string) => `javelin:filter:${boardId}`; 28 | 29 | export default function ViewBoardPage(): JSX.Element { 30 | const auth = useAuth(); 31 | const router = useRouter(); 32 | const board = useBoard(router.query.boardId as string); 33 | const [showSettings, setShowSettings] = React.useState(false); 34 | const [labelFilter, setLabelFilter] = React.useState>( 35 | {} 36 | ); 37 | 38 | const boardLabels = React.useMemo(() => { 39 | if (!board) { 40 | return null; 41 | } 42 | 43 | return [ 44 | ...board.labels, 45 | ...(board.config.markStaleMinutes > 0 && 46 | !board.labels.find((label) => label.key === "stale") 47 | ? [{ key: "stale", color: DEFAULT_TAG_COLOR }] 48 | : []), 49 | ].sort(); 50 | }, [board]); 51 | 52 | React.useEffect(() => { 53 | if (!board) { 54 | return; 55 | } 56 | 57 | setLabelFilter((currentLabelFilter) => { 58 | const newLabelFilter: Record = {}; 59 | const savedLabelFilter: Record = loadFromLocalStorage( 60 | FILTER_STORAGE_KEY(board.id), 61 | {} 62 | ); 63 | 64 | for (const label of boardLabels) { 65 | newLabelFilter[label.key] = 66 | currentLabelFilter[label.key] ?? savedLabelFilter[label.key] ?? true; 67 | } 68 | 69 | return newLabelFilter; 70 | }); 71 | }, [board, boardLabels]); 72 | 73 | const isBoardOwner = board && auth && board.ownerId === auth.uid; 74 | 75 | return ( 76 | <> 77 |
78 | 79 | {board ? ( 80 | <> 81 | 88 |
89 |
90 |

{board.title}

91 |
{board.description}
92 |
93 |
94 | {boardLabels.map((label) => ( 95 | /* TODO lighten background color instead of using opacity */ 96 | { 106 | setLabelFilter((currentLabelFilter) => { 107 | const newLabelFilter = { 108 | ...currentLabelFilter, 109 | [label.key]: !currentLabelFilter[label.key], 110 | }; 111 | 112 | saveToLocalStorage( 113 | FILTER_STORAGE_KEY(board.id), 114 | newLabelFilter 115 | ); 116 | 117 | return newLabelFilter; 118 | }); 119 | }} 120 | > 121 | {label.key} 122 | 123 | ))} 124 |
125 |
126 |
127 | {isBoardOwner ? ( 128 | 135 | ) : null} 136 |
137 |
138 |
139 |
146 | 151 |
152 |
153 | 154 | ) : ( 155 | 159 | )} 160 |
161 | 162 | setShowSettings(false)} 167 | /> 168 | 169 | ); 170 | } 171 | 172 | function BoardView({ 173 | board, 174 | boardLabels, 175 | labelFilter, 176 | }: { 177 | board: Board; 178 | boardLabels: Board["labels"]; 179 | labelFilter: Record; 180 | }): JSX.Element { 181 | const auth = useAuth(); 182 | const sections = useBoardSections(board.id); 183 | const sectionCards = useBoardCards(board.id); 184 | const users = useBoardUsers(board.id); 185 | 186 | const labelColorMap = React.useMemo(() => { 187 | return createLabelColorMap(boardLabels); 188 | }, [boardLabels]); 189 | 190 | const processTags = boardLabels.length > 0; 191 | 192 | return ( 193 | 194 | {sections ? ( 195 | 196 | {sections.map((section, sectionIndex) => ( 197 | 198 |

204 | {section.title} 205 |

206 | {auth ? ( 207 | 227 | ) : null} 228 | 229 | {(sectionCards?.[section.id] || []) 230 | .sort((cardA, cardB) => cardB.timeCreated - cardA.timeCreated) 231 | .map<[Card, string[]]>((card) => [ 232 | card, 233 | unique( 234 | [ 235 | ...(card.content.match(/#\w+/g) || []) 236 | .map((str) => str.slice(1)) 237 | .filter((str) => 238 | boardLabels.find((label) => label.key === str) 239 | ), 240 | board.config.markStaleMinutes && 241 | dayjs().diff(card.timeCreated, "m") >= 242 | board.config.markStaleMinutes && 243 | "stale", 244 | ].filter((v) => v) 245 | ).sort(), 246 | ]) 247 | .filter( 248 | ([, tags]) => 249 | tags.length === 0 || tags.find((tag) => labelFilter[tag]) 250 | ) 251 | .map(([card, tags]) => { 252 | return ( 253 | user.id === card.userId) || null 259 | } 260 | editable={Boolean(auth)} 261 | processTags={processTags} 262 | canMoveLeft={sectionIndex > 0} 263 | canMoveRight={sectionIndex < sections.length - 1} 264 | showCreator={board.config.showCardCreator} 265 | showTimestamp={board.config.showTimestamp} 266 | showRemove={ 267 | board.config.removeCardOnlyOwner 268 | ? board.ownerId === (auth && auth.uid) 269 | : true 270 | } 271 | labelColors={labelColorMap} 272 | tags={tags} 273 | onMove={(direction) => { 274 | const newSectionId = 275 | sections[ 276 | sectionIndex + (direction === "left" ? -1 : 1) 277 | ].id; 278 | 279 | moveCard({ 280 | boardId: board.id, 281 | sectionIdFrom: section.id, 282 | cardId: card.id, 283 | sectionIdTo: newSectionId, 284 | }).catch((error) => alert(error.message)); 285 | }} 286 | onTextUpdate={(text) => { 287 | updateCard({ 288 | boardId: board.id, 289 | sectionId: section.id, 290 | cardId: card.id, 291 | content: text, 292 | }).catch((error) => alert(error.message)); 293 | }} 294 | onRemove={() => { 295 | removeCard({ 296 | boardId: board.id, 297 | sectionId: section.id, 298 | cardId: card.id, 299 | }).catch((error) => alert(error.message)); 300 | }} 301 | /> 302 | ); 303 | })} 304 | 305 | ))} 306 |
307 | ) : ( 308 | 312 | )} 313 |
314 | ); 315 | } 316 | 317 | function createLabelColorMap(labels: Board["labels"]) { 318 | const map: Record = {}; 319 | 320 | for (const label of labels || []) { 321 | if (map[label.key]) { 322 | continue; 323 | } 324 | 325 | map[label.key] = label.color; 326 | } 327 | 328 | return map; 329 | } 330 | 331 | function unique(array: T[]): T[] { 332 | return [...new Set(array)]; 333 | } 334 | 335 | function saveToLocalStorage(key: string, value: unknown): void { 336 | const json = JSON.stringify(value); 337 | localStorage.setItem(key, json); 338 | } 339 | 340 | function loadFromLocalStorage(key: string, defaultValue = null): T { 341 | const json = localStorage.getItem(key); 342 | if (json === null) { 343 | return defaultValue; 344 | } 345 | 346 | return JSON.parse(json); 347 | } 348 | -------------------------------------------------------------------------------- /services/firebase/board.ts: -------------------------------------------------------------------------------- 1 | import firebase from "firebase/app"; 2 | import { createDebug } from "@/utils/log"; 3 | import * as React from "react"; 4 | import { Auth, useAuth } from "./auth"; 5 | import DOMPurify from "dompurify"; 6 | 7 | const db = () => firebase.firestore(); 8 | const debug = createDebug("firebase-board"); 9 | 10 | export const DEFAULT_TAG_COLOR = "#949494"; 11 | export interface Board { 12 | id: string; 13 | ownerId: string; 14 | title: string; 15 | description: string; 16 | sectionCount: number; 17 | config: { 18 | showCardCreator: boolean; 19 | showTimestamp: boolean; 20 | removeCardOnlyOwner: boolean; 21 | markStaleMinutes: number; 22 | }; 23 | labels: { 24 | key: string; 25 | color: string; 26 | }[]; 27 | } 28 | 29 | export interface User { 30 | id: string; 31 | displayName: string; 32 | photoURL: string; 33 | } 34 | 35 | export interface Section { 36 | id: string; 37 | boardId: string; 38 | title: string; 39 | } 40 | 41 | export interface Card { 42 | id: string; 43 | boardId: string; 44 | sectionId: string; 45 | userId: string; 46 | content: string; 47 | timeCreated: number; 48 | timeUpdated: number; 49 | } 50 | 51 | const DEFAULT_BOARD_CONFIG: Board["config"] = { 52 | showCardCreator: true, 53 | showTimestamp: true, 54 | removeCardOnlyOwner: false, 55 | markStaleMinutes: 0, 56 | }; 57 | 58 | const DEFAULT_BOARD_LABELS: Board["labels"] = [ 59 | { key: "stale", color: DEFAULT_TAG_COLOR }, 60 | ]; 61 | 62 | function toBoard({ 63 | data, 64 | id, 65 | }: { 66 | data: firebase.firestore.DocumentData; 67 | id: string; 68 | }): Board { 69 | return { 70 | id, 71 | ownerId: data.ownerId, 72 | title: data.title, 73 | sectionCount: data.sectionCount, 74 | description: data.description || null, 75 | config: { 76 | ...DEFAULT_BOARD_CONFIG, 77 | ...(data.config || {}), 78 | }, 79 | labels: data.labels || [...DEFAULT_BOARD_LABELS], 80 | }; 81 | } 82 | 83 | function toSection({ 84 | data, 85 | id, 86 | boardId, 87 | }: { 88 | data: firebase.firestore.DocumentData; 89 | id: string; 90 | boardId: string; 91 | }): Section { 92 | return { 93 | id, 94 | boardId, 95 | title: data.title, 96 | }; 97 | } 98 | 99 | function toCard({ 100 | data, 101 | id, 102 | boardId, 103 | sectionId, 104 | }: { 105 | data: firebase.firestore.DocumentData; 106 | id: string; 107 | boardId: string; 108 | sectionId: string; 109 | }): Card { 110 | return { 111 | id, 112 | boardId, 113 | sectionId, 114 | userId: data.userId, 115 | content: data.content, 116 | timeCreated: timestampToMiliseconds(data.timeCreated), 117 | timeUpdated: timestampToMiliseconds(data.timeUpdated), 118 | }; 119 | } 120 | 121 | function toUser({ 122 | data, 123 | id, 124 | }: { 125 | data: firebase.firestore.DocumentData; 126 | id: string; 127 | }): User { 128 | return { 129 | id, 130 | displayName: data.displayName, 131 | photoURL: data.photoURL, 132 | }; 133 | } 134 | 135 | export async function getMyBoards(uid: string): Promise { 136 | const boards: Board[] = []; 137 | 138 | const querySnapshot = await db() 139 | .collection("boards") 140 | .where("ownerId", "==", uid) 141 | .get(); 142 | 143 | querySnapshot.forEach((result) => { 144 | boards.push( 145 | toBoard({ 146 | id: result.id, 147 | data: result.data(), 148 | }) 149 | ); 150 | }); 151 | debug("get my boards"); 152 | 153 | return boards; 154 | } 155 | 156 | export async function createBoard({ 157 | userId, 158 | title, 159 | sectionTitles, 160 | description = null, 161 | config = {}, 162 | labels = [...DEFAULT_BOARD_LABELS], 163 | }: { 164 | userId: string; 165 | title: Board["title"]; 166 | sectionTitles: string[]; 167 | description?: Board["description"]; 168 | config?: Partial; 169 | labels?: Board["labels"]; 170 | }): Promise { 171 | const batch = db().batch(); 172 | 173 | const boardRef = db().collection("boards").doc(); 174 | batch.set(boardRef, { 175 | title, 176 | ownerId: userId, 177 | sectionCount: sectionTitles.length, 178 | description, 179 | config: { 180 | ...DEFAULT_BOARD_CONFIG, 181 | ...config, 182 | }, 183 | labels, 184 | }); 185 | 186 | for (const [i, title] of sectionTitles.entries()) { 187 | const sectionRef = boardRef.collection("sections").doc(`section${i + 1}`); 188 | batch.set(sectionRef, { title }); 189 | } 190 | 191 | await batch.commit(); 192 | debug("created board"); 193 | 194 | return boardRef.id; 195 | } 196 | 197 | export async function updateBoard({ 198 | id, 199 | title, 200 | description, 201 | config, 202 | labels, 203 | }: { 204 | id: string; 205 | title?: Board["title"]; 206 | description?: Board["description"]; 207 | config?: Board["config"]; 208 | labels?: Board["labels"]; 209 | }): Promise { 210 | await db() 211 | .collection("boards") 212 | .doc(id) 213 | .update(pick({ title, description, config, labels })); 214 | debug("updated board"); 215 | } 216 | 217 | export async function createCard({ 218 | auth, 219 | boardId, 220 | sectionId, 221 | content = "", 222 | }: { 223 | auth: Auth; 224 | boardId: string; 225 | sectionId: string; 226 | userId: string; 227 | content?: string; 228 | }): Promise { 229 | await db() 230 | .batch() 231 | .set( 232 | db() 233 | .collection("boards") 234 | .doc(boardId) 235 | .collection("sections") 236 | .doc(sectionId) 237 | .collection("cards") 238 | .doc(), 239 | { 240 | userId: auth.uid, 241 | content, 242 | timeCreated: firebase.firestore.FieldValue.serverTimestamp(), 243 | timeUpdated: firebase.firestore.FieldValue.serverTimestamp(), 244 | } 245 | ) 246 | .set( 247 | db().collection("boards").doc(boardId).collection("users").doc(auth.uid), 248 | { 249 | displayName: auth.displayName, 250 | photoURL: auth.photoURL, 251 | } 252 | ) 253 | .commit(); 254 | debug("created card"); 255 | } 256 | 257 | export async function updateCard({ 258 | boardId, 259 | sectionId, 260 | cardId, 261 | content, 262 | }: { 263 | boardId: string; 264 | sectionId: string; 265 | cardId: string; 266 | content?: string; 267 | }): Promise { 268 | await db() 269 | .collection("boards") 270 | .doc(boardId) 271 | .collection("sections") 272 | .doc(sectionId) 273 | .collection("cards") 274 | .doc(cardId) 275 | .update({ 276 | ...pick({ 277 | content: DOMPurify.sanitize(content, { 278 | ALLOWED_TAGS: [], 279 | }), 280 | }), 281 | timeUpdated: firebase.firestore.FieldValue.serverTimestamp(), 282 | }); 283 | debug("updated card"); 284 | } 285 | 286 | export async function removeCard({ 287 | boardId, 288 | sectionId, 289 | cardId, 290 | }: { 291 | boardId: string; 292 | sectionId: string; 293 | cardId: string; 294 | }): Promise { 295 | await db() 296 | .collection("boards") 297 | .doc(boardId) 298 | .collection("sections") 299 | .doc(sectionId) 300 | .collection("cards") 301 | .doc(cardId) 302 | .delete(); 303 | debug("removed card"); 304 | } 305 | 306 | export async function moveCard({ 307 | boardId, 308 | cardId, 309 | sectionIdFrom: sectionId, 310 | sectionIdTo: newSectionId, 311 | }: { 312 | boardId: string; 313 | cardId: string; 314 | sectionIdFrom: string; 315 | sectionIdTo: string; 316 | }): Promise { 317 | const cardData = ( 318 | await db() 319 | .collection("boards") 320 | .doc(boardId) 321 | .collection("sections") 322 | .doc(sectionId) 323 | .collection("cards") 324 | .doc(cardId) 325 | .get() 326 | ).data(); 327 | 328 | const batch = db().batch(); 329 | 330 | const oldCardRef = db() 331 | .collection("boards") 332 | .doc(boardId) 333 | .collection("sections") 334 | .doc(sectionId) 335 | .collection("cards") 336 | .doc(cardId); 337 | batch.delete(oldCardRef); 338 | 339 | const newCardRef = db() 340 | .collection("boards") 341 | .doc(boardId) 342 | .collection("sections") 343 | .doc(newSectionId) 344 | .collection("cards") 345 | .doc(cardId); 346 | batch.set(newCardRef, { 347 | content: cardData.content, 348 | timeCreated: cardData.timeCreated, 349 | timeUpdated: firebase.firestore.FieldValue.serverTimestamp(), 350 | userId: cardData.userId, 351 | }); 352 | 353 | await batch.commit(); 354 | debug("move card"); 355 | } 356 | 357 | export function useBoard(boardId: string): Board { 358 | const [board, setBoard] = React.useState(); 359 | 360 | React.useEffect(() => { 361 | if (!boardId) { 362 | return; 363 | } 364 | 365 | return db() 366 | .collection("boards") 367 | .doc(boardId) 368 | .onSnapshot((snapshot) => { 369 | if (snapshot.exists) { 370 | setBoard( 371 | toBoard({ 372 | id: snapshot.id, 373 | data: snapshot.data(), 374 | }) 375 | ); 376 | debug("read board snapshot"); 377 | } else { 378 | setBoard(null); 379 | } 380 | }); 381 | }, [boardId]); 382 | 383 | return board; 384 | } 385 | 386 | export function useBoardSections(boardId: string): Section[] { 387 | const [sections, setSections] = React.useState(); 388 | 389 | React.useEffect(() => { 390 | if (!boardId) { 391 | return; 392 | } 393 | 394 | return db() 395 | .collection("boards") 396 | .doc(boardId) 397 | .collection("sections") 398 | .onSnapshot((querySnapshot) => { 399 | const newSections: typeof sections = []; 400 | 401 | querySnapshot.forEach((result) => { 402 | newSections.push( 403 | toSection({ 404 | id: result.id, 405 | data: result.data(), 406 | boardId, 407 | }) 408 | ); 409 | }); 410 | 411 | setSections(newSections); 412 | debug("read board sections snapshot"); 413 | }); 414 | }, [boardId]); 415 | 416 | return sections; 417 | } 418 | 419 | export function useBoardUsers(boardId: string | false): User[] { 420 | const auth = useAuth(); 421 | const [users, setUsers] = React.useState(); 422 | 423 | React.useEffect(() => { 424 | if (!auth || !boardId) { 425 | return; 426 | } 427 | 428 | return db() 429 | .collection("boards") 430 | .doc(boardId) 431 | .collection("users") 432 | .onSnapshot((querySnapshot) => { 433 | const newUsers: typeof users = []; 434 | 435 | querySnapshot.forEach((result) => { 436 | newUsers.push( 437 | toUser({ 438 | data: result.data(), 439 | id: result.id, 440 | }) 441 | ); 442 | }); 443 | 444 | setUsers(newUsers); 445 | debug("read board users snapshot"); 446 | }); 447 | }, [auth, boardId]); 448 | 449 | return users; 450 | } 451 | 452 | export function useBoardCards(boardId: string): Record { 453 | const [sectionCards, setSectionCards] = React.useState< 454 | Record 455 | >({}); 456 | 457 | React.useEffect(() => { 458 | if (!boardId) { 459 | return; 460 | } 461 | 462 | const unsubscribes = [ 463 | db() 464 | .collection("boards") 465 | .doc(boardId) 466 | .collection("sections") 467 | .onSnapshot((querySectionSnapshot) => { 468 | querySectionSnapshot.forEach((sectionResult) => { 469 | unsubscribes.push( 470 | sectionResult.ref 471 | .collection("cards") 472 | .onSnapshot((queryCardSnapshot) => { 473 | const newCards: Card[] = []; 474 | 475 | queryCardSnapshot.forEach((cardResult) => { 476 | newCards.push( 477 | toCard({ 478 | data: cardResult.data(), 479 | id: cardResult.id, 480 | boardId: boardId, 481 | sectionId: sectionResult.id, 482 | }) 483 | ); 484 | }); 485 | 486 | setSectionCards((oldSectionCards) => ({ 487 | ...oldSectionCards, 488 | [sectionResult.id]: newCards, 489 | })); 490 | debug("read section cards snapshot"); 491 | }) 492 | ); 493 | }); 494 | debug("read sections snapshot"); 495 | }), 496 | ]; 497 | 498 | return () => unsubscribes.forEach((fn) => fn()); 499 | }, [boardId]); 500 | 501 | return sectionCards; 502 | } 503 | 504 | function pick(objIn: Record): Record { 505 | const objOut = {}; 506 | 507 | for (const [key, value] of Object.entries(objIn)) { 508 | if (typeof value === "undefined") { 509 | continue; 510 | } 511 | objOut[key] = value; 512 | } 513 | 514 | return objOut; 515 | } 516 | 517 | function timestampToMiliseconds(timestamp: { 518 | seconds: number; 519 | nanoseconds: number; 520 | }): number { 521 | // Timestamps can be null if data is from cache. 522 | // Here we use an approximation Date.now(), since it is 523 | // probably the state when the card is first created. 524 | if (!timestamp) { 525 | return Date.now(); 526 | } 527 | 528 | const { seconds, nanoseconds } = timestamp; 529 | return seconds * 1000 + Math.trunc(nanoseconds / 1000000); 530 | } 531 | --------------------------------------------------------------------------------