├── .circleci └── config.yml ├── .dockerignore ├── .editorconfig ├── .env ├── .formatter.exs ├── .gitignore ├── .iex.exs ├── CHANGELOG.md ├── LICENSE.txt ├── Makefile ├── README.md ├── TODO.md ├── assets ├── .babelrc ├── css │ ├── app.scss │ ├── base │ │ ├── _colors.scss │ │ ├── _form.scss │ │ └── _type.scss │ ├── components │ │ ├── _buttons.scss │ │ ├── _card.scss │ │ ├── _columns.scss │ │ ├── _dragAndDrop.scss │ │ ├── _dropDown.scss │ │ ├── _footer.scss │ │ ├── _icons.scss │ │ ├── _modal.scss │ │ ├── _nav.scss │ │ ├── _notification.scss │ │ ├── _search.scss │ │ └── _tab.scss │ ├── objects │ │ ├── _column.scss │ │ └── _stack.scss │ ├── settings │ │ ├── _bulma.scss │ │ └── _settings.scss │ ├── themes │ │ ├── dark.scss │ │ ├── dark │ │ │ ├── _buttons.scss │ │ │ ├── _card.scss │ │ │ ├── _colors.scss │ │ │ ├── _dragAndDrop.scss │ │ │ ├── _dropDown.scss │ │ │ ├── _form.scss │ │ │ ├── _modal.scss │ │ │ ├── _search.scss │ │ │ ├── _tab.scss │ │ │ └── _type.scss │ │ ├── light.scss │ │ └── light │ │ │ ├── _card.scss │ │ │ ├── _colors.scss │ │ │ ├── _dragAndDrop.scss │ │ │ ├── _dropDown.scss │ │ │ ├── _form.scss │ │ │ ├── _search.scss │ │ │ ├── _tab.scss │ │ │ └── _type.scss │ └── utilities │ │ ├── _spacing.scss │ │ └── _utilities.scss ├── js │ ├── app.js │ ├── cyborg.js │ ├── datalist_helper.js │ ├── dnd.js │ ├── hamburger.js │ └── socket.js ├── ops │ ├── config.fish │ ├── dev │ │ ├── Dockerfile │ │ └── docker-compose.yml │ ├── release │ │ └── Dockerfile │ └── setup_security.sh ├── package-lock.json ├── package.json ├── static │ ├── favicon.ico │ ├── fonts │ │ └── Arsenal-Regular.ttf │ └── robots.txt └── webpack.config.js ├── bin ├── db ├── dev ├── down ├── dp ├── maybe_create_secret_files └── psql ├── config ├── config.exs ├── dev.exs ├── docker.env ├── prod.exs ├── releases.exs └── test.exs ├── docs └── authentication.md ├── lib ├── lucidboard.ex ├── lucidboard │ ├── account.ex │ ├── account │ │ ├── github.ex │ │ └── ping_fed.ex │ ├── application.ex │ ├── ecto_enums.ex │ ├── live_board.ex │ ├── live_board │ │ ├── agent.ex │ │ └── scribe.ex │ ├── presence.ex │ ├── release.ex │ ├── repo.ex │ ├── schema │ │ ├── board.ex │ │ ├── board_role.ex │ │ ├── board_settings.ex │ │ ├── card.ex │ │ ├── card_settings.ex │ │ ├── column.ex │ │ ├── event.ex │ │ ├── like.ex │ │ ├── pile.ex │ │ ├── user.ex │ │ └── user_settings.ex │ ├── seeds.ex │ ├── short_board.ex │ ├── time_machine.ex │ ├── twiddler.ex │ └── twiddler │ │ ├── actions.ex │ │ ├── glass.ex │ │ ├── lensables.ex │ │ ├── op.ex │ │ └── query_builder.ex ├── lucidboard_web.ex ├── lucidboard_web │ ├── channels │ │ └── user_socket.ex │ ├── controllers │ │ ├── auth_controller.ex │ │ ├── board_controller.ex │ │ ├── fallback_controller.ex │ │ ├── page_controller.ex │ │ └── user_controller.ex │ ├── endpoint.ex │ ├── gettext.ex │ ├── live │ │ ├── board_live.ex │ │ ├── board_live │ │ │ ├── helper.ex │ │ │ └── search.ex │ │ ├── create_board_live.ex │ │ ├── dashboard_live.ex │ │ └── live_helper.ex │ ├── plugs │ │ └── load_user_plug.ex │ ├── router.ex │ ├── templates │ │ ├── board │ │ │ ├── _access_field.html.eex │ │ │ ├── _roles.html.eex │ │ │ ├── board.html.eex │ │ │ ├── card.html.eex │ │ │ ├── create_board.html.leex │ │ │ ├── events.html.eex │ │ │ ├── index.html.leex │ │ │ ├── modal_card.html.eex │ │ │ ├── options-show.html.eex │ │ │ ├── options.html.eex │ │ │ └── pile.html.leex │ │ ├── dashboard │ │ │ └── index.html.leex │ │ ├── layout │ │ │ ├── accoutrements │ │ │ │ ├── footer.html.eex │ │ │ │ ├── header.html.eex │ │ │ │ └── navbar_menu.html.eex │ │ │ ├── app.html.eex │ │ │ ├── full_width.html.eex │ │ │ ├── header_user_menu.html.eex │ │ │ └── normal.html.eex │ │ ├── page │ │ │ └── index.html.eex │ │ └── user │ │ │ ├── settings.html.eex │ │ │ └── signin.html.eex │ ├── view_helper.ex │ └── views │ │ ├── board_view.ex │ │ ├── dashboard_view.ex │ │ ├── error_helpers.ex │ │ ├── error_view.ex │ │ ├── layout_view.ex │ │ ├── page_view.ex │ │ └── user_view.ex └── release_tasks.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot └── repo │ ├── migrations │ ├── 20180507203357_initial.exs │ ├── 20190903172452_private_boards.exs │ ├── 20190903190049_admin.exs │ └── 20190906183517_anonymous_boards.exs │ └── seeds.exs ├── rel ├── config.exs ├── config │ └── config.toml ├── hooks │ └── post_start │ │ └── migrate.sh ├── plugins │ └── .gitignore └── vm.args └── test ├── lucidboard ├── account_test.exs ├── glass_test.exs ├── live_board_test.exs ├── twiddler │ └── op_test.exs └── twiddler_test.exs ├── lucidboard_web ├── controllers │ └── page_controller_test.exs ├── view_helper_test.exs └── views │ ├── error_view_test.exs │ ├── layout_view_test.exs │ └── page_view_test.exs ├── support ├── board_case.ex ├── board_fixtures.ex ├── channel_case.ex ├── conn_case.ex └── data_case.ex └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Elixir CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-elixir/ for more details 4 | version: 2 5 | jobs: 6 | build: 7 | docker: 8 | - image: circleci/elixir:latest 9 | environment: 10 | MIX_ENV: test 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | - image: circleci/postgres:10.1-alpine 16 | environment: 17 | POSTGRES_USER: postgres 18 | POSTGRES_PASSWORD: verysecure123 19 | 20 | working_directory: ~/repo 21 | steps: 22 | - checkout 23 | 24 | - run: mix local.hex --force 25 | - run: mix local.rebar --force 26 | - run: mix deps.get 27 | - run: mix ecto.setup 28 | - run: mix test 29 | - run: mix lint 30 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | deps/ 3 | .git/ 4 | .gitignore 5 | Dockerfile 6 | Makefile 7 | README* 8 | test/ 9 | priv/static/ 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | QL_MODE=0 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}", "priv/repo/**/*.exs"], 3 | line_length: 80 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # App artifacts 2 | /_build 3 | /db 4 | /deps 5 | /*.ez 6 | .idea 7 | .vscode 8 | 9 | # Generated on crash by the VM 10 | erl_crash.dump 11 | 12 | # Generated on crash by NPM 13 | npm-debug.log 14 | 15 | # Static artifacts 16 | /assets/node_modules 17 | 18 | # Since we are building assets from assets/, 19 | # we ignore priv/static. You may want to comment 20 | # this depending on your deployment strategy. 21 | /priv/static/ 22 | 23 | # Files matching config/*.secret.exs pattern contain sensitive 24 | # data and you should not commit them into version control. 25 | # 26 | # Alternatively, you may comment the line below and commit the 27 | # secrets files as long as you replace their contents by environment 28 | # variables. 29 | /config/*.secret.exs 30 | 31 | .elixir_ls 32 | .DS_Store 33 | /cover 34 | -------------------------------------------------------------------------------- /.iex.exs: -------------------------------------------------------------------------------- 1 | import Ecto.Query 2 | alias Ecto.{Changeset, UUID} 3 | alias Lucidboard.{ 4 | Account, 5 | Board, 6 | BoardRole, 7 | BoardSettings, 8 | Column, 9 | Card, 10 | Event, 11 | Glass, 12 | Like, 13 | LiveBoard, 14 | Pile, 15 | Presence, 16 | TimeMachine, 17 | User, 18 | UserSettings, 19 | CardSettings 20 | } 21 | 22 | alias Lucidboard.LiveBoard, as: Liveboard 23 | alias Lucidboard.Repo, as: Repo 24 | alias Lucidboard.Twiddler, as: Twiddler 25 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2019-09-06 4 | 5 | * Private boards 6 | * Anonymous boards 7 | * Contributor access can now be granted 8 | * New search UI on board screen 9 | * Automatically focus card text area 10 | * Dashboard listing is fully live and receiving updates 11 | * New message feedback style for usability 12 | * Admin users 13 | * Lots of fixes & cleanup 14 | 15 | ## 2019-08-18 16 | 17 | * Create new board screen is now a LiveView. 18 | * Started `CHANGELOG.md` & added it to the site at `/changelog` 19 | * Added a footer with useful links 20 | * Created "show" board options screen for non-owners 21 | * Fixed likes per user per card validation 22 | * Switched to Elixir 1.9-based releases for deployment 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2018 Adam Bellinson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | 3 | APP_NAME ?= `grep 'app:' mix.exs | sed -e 's/\[//g' -e 's/ //g' -e 's/app://' -e 's/[:,]//g' | head -n1` 4 | APP_VSN ?= `grep 'version:' mix.exs | cut -d '"' -f2` 5 | BUILD ?= `git rev-parse --short HEAD` 6 | QL_MODE ?= 0 7 | 8 | help: 9 | @echo "$(APP_NAME):$(APP_VSN)-$(BUILD)" 10 | @perl -nle'print $& if m{^[a-zA-Z_-]+:.*?## .*$$}' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 11 | 12 | build: ## Build the Docker image 13 | docker build --build-arg APP_NAME=$(APP_NAME) \ 14 | -f assets/ops/release/Dockerfile \ 15 | --build-arg APP_VSN=$(APP_VSN) \ 16 | --build-arg QL_MODE=$(QL_MODE) \ 17 | -t $(APP_NAME):$(APP_VSN)-$(BUILD) \ 18 | -t $(APP_NAME):latest . 19 | 20 | run: ## Run the app in Docker 21 | docker run --env-file config/docker.env \ 22 | --expose 4000 -p 4000:4000 \ 23 | --rm -it $(APP_NAME):latest 24 | 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lucidboard 2 | 3 | A realtime, collaborative kanban tool, built on 4 | [Elixir](https://elixir-lang.org/), [Phoenix](https://phoenixframework.org/), 5 | and 6 | [LiveView](https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript). 7 | 8 | **Status:** We've met our MVP goals! Now we're just adding features. As always, 9 | pull requests welcome! 10 | 11 | **CI:** [Lucidboard on CircleCI](https://circleci.com/gh/djthread/lucidboard) [![CircleCI](https://circleci.com/gh/djthread/lucidboard.svg?style=svg)](https://circleci.com/gh/djthread/lucidboard) 12 | 13 | To start your Phoenix development environment: 14 | 15 | ```bash 16 | bin/dev 17 | ``` 18 | 19 | **Note:** If you are on the Quicken Loans network, you'll want to invoke this 20 | script with `bin/dev --ql` or you will get errors around HTTPS authentication. 21 | 22 | The [script's comments](bin/dev) explain a bit more, but you'll get two 23 | docker containers -- a Postgres database (`lucidboard_dev_db`) and an Elixir 24 | development container (`lucidboard_dev_app`). The script will then run the 25 | fish shell inside the latter, dropping you into `/app` where the project 26 | files reside. 27 | 28 | When running this the first time, you'll need to install the dependencies and 29 | initialize the database. (You may also simply type `setup` since it is an alias 30 | for these commands.) 31 | 32 | ```bash 33 | mix deps.get 34 | cd assets; npm install; cd .. 35 | mix ecto.setup 36 | ``` 37 | 38 | Finally, start the application with `imp`. This is an alias for `iex -S mix 39 | phx.server` which will run the app with Elixir's interactive repl, iex. This 40 | will allow you to test lines of Elixir code and interact with the running 41 | application. `imp` is the only command you'll need next time, now that things 42 | are installed. 43 | 44 | ```bash 45 | imp 46 | ``` 47 | 48 | Now you can visit [`localhost:8800`](http://localhost:8800) from your browser. 49 | 50 | To close down and remove the docker containers, run the following script. 51 | Don't worry - all your code and database data will remain intact for next 52 | time. 53 | 54 | ```bash 55 | bin/down 56 | ``` 57 | 58 | ## Shell Aliases 59 | 60 | These [recommended few](assets/ops/config.fish) aliases are imported to the 61 | fish shell in the docker-based dev environment. 62 | 63 | | Alias | Full Command | 64 | | ------- | --------------------- | 65 | | `imp` | iex -S mix phx.server | 66 | | `im` | iex -S mix | 67 | | `mdg` | iex mix deps.get | 68 | | `mdu` | mix deps.update --all | 69 | | `mt` | mix test | 70 | | `mtw` | mix test.watch | 71 | | `ml` | mix lint | 72 | | `mer` | mix ecto.reset | 73 | | `setup` | mix deps.get
cd assets; npm install; cd ..
mix ecto.setup | 74 | 75 | ## License 76 | 77 | Per the included [`LICENSE.txt`](LICENSE.txt), Lucidboard is made available 78 | under the MIT license. -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | Lucidboard Features 2 | =================== 3 | 4 | ## Hack Week TODOs 5 | 6 | - (finish) Column deleting 7 | 8 | - Send messages into a board event feed 9 | - Assign colors to cards 10 | - Unliking 11 | - Board freezing 12 | - Private boards 13 | 14 | - investigate db indexes 15 | 16 | - investigate db indexes 17 | 18 | ## Needed before pilot 19 | 20 | ### Backend 21 | 22 | * Network Logins 23 | 24 | ### Frontend 25 | 26 | * pile controls css 27 | * consider where one drags from on it. might need a special grip area? 28 | * board options pane 29 | * locked card 30 | * home page (hero) 31 | * all of the things 32 | 33 | #### other things to check 34 | 35 | * dashboard listing 36 | * drag/drop junctions 37 | * events 38 | 39 | ## Ideas and Nice-to-haves 40 | 41 | 42 | ### maybes 43 | 44 | Reorder columns 45 | board list a liveview? 46 | rename board 47 | delete board? 48 | freeze board? 49 | 50 | 51 | ### future things? 52 | 53 | fancy guid or whatever-based board urls 54 | permissions and board ownership 55 | private boards 56 | auto groups 57 | starred board 58 | timer maybe ? 59 | search 60 | 61 | ## Permission notes 62 | 63 | - Roles 64 | - Observer 65 | - Contributor 66 | - Owner 67 | --------------------------- 68 | - Admin? (Global) 69 | -------------------------------------------------------------------------------- /assets/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /assets/css/app.scss: -------------------------------------------------------------------------------- 1 | $fa-font-path: "../node_modules/@fortawesome/fontawesome-free/webfonts"; 2 | @import "../node_modules/@fortawesome/fontawesome-free/scss/fontawesome"; 3 | @import "../node_modules/@fortawesome/fontawesome-free/scss/solid"; 4 | @import "../node_modules/@fortawesome/fontawesome-free/scss/brands"; 5 | 6 | @import "settings/settings"; 7 | 8 | @import "base/colors"; 9 | @import "base/form"; 10 | @import "base/type"; 11 | @import "objects/column"; 12 | @import "objects/stack"; 13 | @import "components/buttons"; 14 | @import "components/icons"; 15 | @import "components/card"; 16 | @import "components/columns"; 17 | @import "components/dragAndDrop"; 18 | @import "components/dropDown"; 19 | @import "components/footer"; 20 | @import "components/modal"; 21 | @import "components/nav"; 22 | @import "components/notification"; 23 | @import "components/search"; 24 | @import "components/tab"; 25 | @import "utilities/spacing"; 26 | @import "utilities/utilities"; 27 | -------------------------------------------------------------------------------- /assets/css/base/_colors.scss: -------------------------------------------------------------------------------- 1 | $grey-light: #8c9b9d; 2 | $grey: darken($grey-light, 18); 3 | $grey-dark: darken($grey, 18); 4 | $grey-darker: darken($grey, 23); 5 | $red: #e74c3c; 6 | 7 | $black: #333333; 8 | $white: #FFFFFF; 9 | -------------------------------------------------------------------------------- /assets/css/base/_form.scss: -------------------------------------------------------------------------------- 1 | // Light theme styles 2 | .t-light .lb-textarea { 3 | color: $black; 4 | } 5 | 6 | // Textarea styles 7 | .lb-textarea { 8 | background-color: transparent; 9 | border: 0; 10 | resize: none; 11 | outline: none; 12 | box-shadow: none; 13 | overflow: hidden; 14 | padding: 0; 15 | 16 | &:focus { 17 | box-shadow: none; 18 | } 19 | } 20 | 21 | .lb-textarea:not([rows]) { 22 | min-height: 1rem; 23 | } 24 | 25 | // Select arrow style 26 | .lb-select:not(.is-multiple):not(.is-loading)::after { 27 | top: 60%; 28 | } 29 | 30 | .lb-input, 31 | .lb-select select { 32 | border-width: 4px; 33 | border-radius: 4px; 34 | box-shadow: none; 35 | height: auto; 36 | font-size: 1rem; 37 | 38 | &:hover, 39 | &:focus, 40 | &:active { 41 | box-shadow: none; 42 | } 43 | } 44 | 45 | .lb-select { 46 | width: 100%; 47 | 48 | @media all and (min-width: $widescreen) { 49 | width: auto; 50 | } 51 | } 52 | 53 | // Tall and bigger font size text input 54 | .lb-input-huge { 55 | height: 50px; 56 | font-size: 1.25rem; 57 | } 58 | 59 | // Input placeholder color per browser 60 | .lb-input::placeholder { /* Chrome, Firefox, Opera, Safari 10.1+ */ 61 | color: $grey-dark; 62 | opacity: 1; /* Firefox */ 63 | } 64 | 65 | .lb-input:-ms-input-placeholder { /* Internet Explorer 10-11 */ 66 | color: $grey-dark; 67 | } 68 | 69 | .lb-input::-ms-input-placeholder { /* Microsoft Edge */ 70 | color: $grey-dark; 71 | } 72 | 73 | .lb-input--alt { 74 | border-top: 0; 75 | border-left: 0; 76 | border-right: 0; 77 | border-radius: 0; 78 | padding: 0 0.5rem; 79 | } 80 | 81 | .lb-errorText { 82 | color: $red; 83 | display: block; 84 | margin-top: 0.5rem; 85 | } 86 | 87 | .lb-radioButton { 88 | margin-bottom: 1rem; 89 | 90 | &:last-child() { 91 | margin-bottom: 0; 92 | } 93 | } 94 | 95 | .lb-radioButton .lb-radioButton__input { 96 | position: absolute; 97 | opacity: 0; 98 | 99 | + .lb-radioButton__label { 100 | display: flex; 101 | align-items: center; 102 | cursor: pointer; 103 | 104 | &::before { 105 | content: ''; 106 | background: $white; 107 | border-radius: 100%; 108 | border: 2px solid #cfcfcf; 109 | display: inline-block; 110 | width: 1.25rem; 111 | height: 1.25rem; 112 | position: relative; 113 | margin-right: 0.5rem; 114 | vertical-align: top; 115 | cursor: pointer; 116 | text-align: center; 117 | transition: all 250ms ease; 118 | } 119 | } 120 | } 121 | 122 | .lb-radioButton .lb-radioButton__input:checked { 123 | + .lb-radioButton__label { 124 | &::before { 125 | background-color: $white; 126 | } 127 | } 128 | } 129 | 130 | .lb-inputTrigger { 131 | display: inline-block; 132 | } 133 | 134 | .lb-inputTrigger > * { 135 | pointer-events: none; 136 | } 137 | 138 | label { 139 | margin-bottom: 0.25rem; 140 | display: inline-block; 141 | } 142 | -------------------------------------------------------------------------------- /assets/css/base/_type.scss: -------------------------------------------------------------------------------- 1 | // Secondary heading 2 | .lb-heading--secondary { 3 | line-height: 1.5; 4 | margin: 0; 5 | font-weight: 400; 6 | font-size: 1.5rem; 7 | } 8 | 9 | // Tertiary(third) heading 10 | .lb-heading--tertiary { 11 | line-height: 1.5; 12 | margin: 0; 13 | font-weight: 400; 14 | font-size: 1.25rem; 15 | } 16 | -------------------------------------------------------------------------------- /assets/css/components/_buttons.scss: -------------------------------------------------------------------------------- 1 | // Height auto on buttons 2 | .lb-button { 3 | height: auto; 4 | border-radius: 4px; 5 | } 6 | 7 | // Plain button 8 | .lb-button--plain { 9 | border: 0; 10 | background-color: transparent; 11 | 12 | &:active, 13 | &:focus { 14 | color: $white; 15 | box-shadow: none; 16 | } 17 | } 18 | 19 | .lb-button--plain:focus:not(:active) { 20 | color: $white; 21 | box-shadow: none; 22 | } 23 | 24 | .lb-buttons--inline > * { 25 | margin-right: 0.5rem; 26 | 27 | &:last-child { 28 | margin-right: 0; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /assets/css/components/_card.scss: -------------------------------------------------------------------------------- 1 | .lb-card { 2 | transition: all .2s ease-in-out; 3 | border-radius: 0.4rem; 4 | box-shadow: none; 5 | max-width: 100%; 6 | position: relative; 7 | } 8 | 9 | .lb-card--grab { 10 | cursor: grab; 11 | } 12 | 13 | .lb-card__content { 14 | word-wrap: break-word; 15 | } 16 | 17 | .lb-card__footer { 18 | display: flex; 19 | transition: all .2s ease-in-out; 20 | } 21 | 22 | .lb-card__footer-user { 23 | align-items: center; 24 | color: $grey-light; 25 | font-style: italic; 26 | display: flex; 27 | flex: 1; 28 | padding-left: 0.75rem; 29 | } 30 | 31 | .lb-card__footer-actions { 32 | display: flex; 33 | } 34 | 35 | .lb-card__footer--extra { 36 | display: flex; 37 | border-bottom-left-radius: 0.4rem; 38 | border-bottom-right-radius: 0.4rem; 39 | } 40 | 41 | .lb-card__footer-item--disabled { 42 | color: $grey; 43 | cursor: not-allowed; 44 | 45 | &:hover, 46 | &:focus { 47 | color: $grey; 48 | } 49 | } 50 | 51 | .lb-card__label { 52 | border-radius: 50%; 53 | box-sizing: border-box; 54 | height: 25px; 55 | margin-left: 0.75rem; 56 | width: 25px; 57 | } 58 | 59 | // Cards on within the manage columns section 60 | .lb-card-manage { 61 | display: flex; 62 | align-items: center; 63 | } 64 | 65 | .lb-card-manage__arrows { 66 | display: flex; 67 | justify-content: space-between; 68 | padding: 0.5rem 1rem; 69 | max-width: 6rem; 70 | min-width: 6rem; 71 | } 72 | 73 | .lb-card-manage__arrows--first { 74 | justify-content: flex-end;; 75 | } 76 | 77 | .lb-card-manage__arrow { 78 | margin-right: 1rem; 79 | display: flex; 80 | justify-content: space-between; 81 | 82 | &:last-child { 83 | margin-right: 0; 84 | } 85 | } 86 | 87 | .lb-card-manage__title { 88 | width: 100%; 89 | padding: 0.5rem 1rem; 90 | } 91 | 92 | // Locked card icon 93 | .lb-card-locked.fa { 94 | padding: 1rem; 95 | display: block; 96 | text-align: center; 97 | } 98 | -------------------------------------------------------------------------------- /assets/css/components/_columns.scss: -------------------------------------------------------------------------------- 1 | .lb-columns { 2 | @media all and (min-width: $widescreen) { 3 | overflow-x: auto; 4 | min-height: calc(100vh - 220px); 5 | } 6 | } 7 | 8 | .lb-column { 9 | @media all and (min-width: $widescreen) { 10 | max-width: 350px; 11 | min-width: 350px; 12 | } 13 | } 14 | 15 | main { 16 | min-height: calc(100vh - 185px); 17 | } 18 | -------------------------------------------------------------------------------- /assets/css/components/_dragAndDrop.scss: -------------------------------------------------------------------------------- 1 | // Drag and drop slot 2 | .lb-dnd-slot { 3 | line-height: 0rem; 4 | height: 1rem; 5 | transition: height .2s ease-in-out; 6 | } 7 | 8 | .lb-dnd-slot.active { 9 | border-radius: 0.4rem; 10 | line-height: 10rem; 11 | height: 8rem; 12 | position: relative; 13 | } 14 | 15 | .dnd-dragging .lb-dnd-slot { 16 | border: 0.5px solid transparent; 17 | } 18 | 19 | .dnd-dragging .lb-card { 20 | cursor: grabbing; 21 | } 22 | -------------------------------------------------------------------------------- /assets/css/components/_dropDown.scss: -------------------------------------------------------------------------------- 1 | .lb-dropdown-button { 2 | border: 0; 3 | font-size: 1rem; 4 | background-color: transparent; 5 | height: 100%; 6 | padding: 0.25rem 1rem; 7 | display: flex; 8 | justify-content: space-between; 9 | align-items: center; 10 | color: $white; 11 | outline: 0 12 | } 13 | 14 | .lb-dropdown-button .icon { 15 | margin-left: 1rem; 16 | } 17 | 18 | .lb-dropdown-menu { 19 | width: 100%; 20 | padding: 0; 21 | } 22 | 23 | .lb-dropdown-content { 24 | box-shadow: none; 25 | } 26 | 27 | .lb-dropdown { 28 | position: relative; 29 | } 30 | 31 | .lb-dropdown-trigger { 32 | outline: 0; 33 | } 34 | -------------------------------------------------------------------------------- /assets/css/components/_footer.scss: -------------------------------------------------------------------------------- 1 | .c-Footer { 2 | width: 100%; 3 | padding: 3rem 2rem; 4 | } 5 | -------------------------------------------------------------------------------- /assets/css/components/_icons.scss: -------------------------------------------------------------------------------- 1 | .lb-icon-avatar { 2 | display: inline-block; 3 | width: 2em; 4 | height: 2em; 5 | border-radius: 50%; 6 | margin-right: 1rem; 7 | background-repeat: no-repeat; 8 | background-position: center center; 9 | background-size: cover; 10 | } 11 | 12 | .lb-icon-logo { 13 | width: 8em; 14 | height: 2em; 15 | } 16 | 17 | .lb-icon--fa { 18 | font-size: 1rem; 19 | } 20 | -------------------------------------------------------------------------------- /assets/css/components/_modal.scss: -------------------------------------------------------------------------------- 1 | .lb-modal-card-title { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: flex-start; 5 | } 6 | 7 | .lb-modal-card-textarea { 8 | background: transparent; 9 | border: 0; 10 | font-size: 1rem; 11 | resize: none; 12 | outline: 0; 13 | height: 1.5rem; 14 | width: 100%; 15 | } 16 | 17 | .lb-modal-card-close { 18 | background: none; 19 | border: 0; 20 | cursor: pointer; 21 | font-size: 1rem; 22 | } 23 | 24 | .lb-modal-radio-button { 25 | position: absolute; 26 | opacity: 0; 27 | } 28 | 29 | .lb-modal-radio-label { 30 | height: 2rem; 31 | width: 8rem; 32 | border-radius: 2px; 33 | display: block; 34 | margin-bottom: 1rem; 35 | display: flex; 36 | justify-content: flex-end; 37 | align-items: center; 38 | 39 | &:last-child() { 40 | margin-bottom: 0; 41 | } 42 | } 43 | 44 | .lb-modal-radio-label--green { 45 | background-color: #2ecc71; 46 | 47 | &:hover { 48 | border-left: 8px solid darken(#2ecc71, 10%); 49 | } 50 | } 51 | 52 | .lb-modal-radio-label--blue { 53 | background-color: #3498db; 54 | 55 | &:hover { 56 | border-left: 8px solid darken(#3498db, 10%); 57 | } 58 | } 59 | 60 | .lb-modal-radio-label--red { 61 | background-color: #e74c3c; 62 | 63 | &:hover { 64 | border-left: 8px solid darken(#e74c3c, 10%); 65 | } 66 | } 67 | 68 | .lb-modal-radio-label--orange { 69 | background-color: #e67e22; 70 | 71 | &:hover { 72 | border-left: 8px solid darken(#e67e22, 10%); 73 | } 74 | } 75 | 76 | .lb-modal-radio-label__icon { 77 | display: none; 78 | margin-right: 0.5rem; 79 | color: $white; 80 | } 81 | 82 | input[type="radio"]:checked+label { 83 | .lb-modal-radio-label__icon { 84 | display: block; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /assets/css/components/_nav.scss: -------------------------------------------------------------------------------- 1 | .lb-navbar.is-primary .navbar-brand > a.navbar-item { 2 | &:hover, 3 | &:focus { 4 | background-color: transparent; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /assets/css/components/_notification.scss: -------------------------------------------------------------------------------- 1 | .lb-notification { 2 | border-radius: 4px; 3 | position: absolute; 4 | width: 100%; 5 | left: 0; 6 | right: 0; 7 | z-index: 9; 8 | top: -1rem; 9 | margin: auto; 10 | font-weight: bold; 11 | font-size: 1.15rem; 12 | text-align: center; 13 | 14 | @media all and (min-width: $widescreen) { 15 | width: 25rem; 16 | } 17 | } 18 | 19 | .lb-notification.is-success { 20 | background-color: #2ecc71d6; 21 | } 22 | 23 | .lb-notification.is-danger { 24 | width: 33rem; 25 | } 26 | -------------------------------------------------------------------------------- /assets/css/components/_search.scss: -------------------------------------------------------------------------------- 1 | .lb-search { 2 | min-width: 24px; 3 | height: 40px; 4 | 5 | @media (min-width: $widescreen) { 6 | height: auto; 7 | } 8 | } 9 | 10 | .lb-search__wrapper { 11 | align-items: center; 12 | display: flex; 13 | height: 35px; 14 | right: calc(100% - 24px); 15 | left: 0; 16 | position: absolute; 17 | top: 0; 18 | transition: right 0.2s, left 0.2s; 19 | border-bottom: 2px solid transparent; 20 | justify-content: flex-start; 21 | 22 | @media (min-width: $widescreen) { 23 | height: 130%; // SafarIE. Will revisit. 24 | } 25 | } 26 | 27 | .lb-search__close { 28 | display: none; 29 | } 30 | 31 | .lb-search__icon { 32 | font-size: 1.25rem; 33 | } 34 | 35 | .lb-search__close__icon { 36 | font-size: 1.5rem; 37 | } 38 | 39 | .lb-search__form { 40 | display: none; 41 | margin: 0 0.5rem; 42 | width: calc(100% - 57px); 43 | } 44 | 45 | .lb-search__input { 46 | width: 100%; 47 | border: 0; 48 | margin: 0; 49 | height: 100%; 50 | 51 | &:focus { 52 | outline: none; 53 | } 54 | } 55 | 56 | .lb-search__wrapper--open { 57 | right: 0; 58 | padding: 0; 59 | 60 | .lb-search__form, 61 | .lb-search__close { 62 | display: inline-flex; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /assets/css/components/_tab.scss: -------------------------------------------------------------------------------- 1 | .lb-tabs { 2 | display: flex; 3 | flex-direction: column; 4 | flex-wrap: wrap; 5 | position: relative; 6 | 7 | @media all and (min-width: $widescreen) { 8 | flex-direction: row; 9 | } 10 | } 11 | 12 | .lb-tab { 13 | margin-right: 1rem; 14 | margin-bottom: 0.5rem; 15 | 16 | &:last-child() { 17 | margin-right: 0; 18 | } 19 | 20 | @media all and (min-width: $widescreen) { 21 | margin-bottom: 0; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /assets/css/objects/_column.scss: -------------------------------------------------------------------------------- 1 | .centered-column { 2 | max-width: 64rem; 3 | margin: 0 auto; 4 | } 5 | -------------------------------------------------------------------------------- /assets/css/objects/_stack.scss: -------------------------------------------------------------------------------- 1 | .stack { 2 | display: flex; 3 | } 4 | 5 | .stack--column { 6 | flex-direction: column; 7 | } 8 | 9 | .stack--split\@m { 10 | flex-direction: column; 11 | 12 | @media all and (min-width: $widescreen) { 13 | display: flex; 14 | flex-direction: row; 15 | } 16 | } 17 | 18 | .stack--split\@xxs { 19 | flex-direction: column; 20 | 21 | @media all and (min-width: 20rem) { 22 | display: flex; 23 | flex-direction: row; 24 | } 25 | } 26 | 27 | .stack--spaceBetween { 28 | justify-content: space-between; 29 | } 30 | 31 | .stack--spaceBetween\@m { 32 | @media all and (min-width: 20rem) { 33 | justify-content: space-between; 34 | } 35 | } 36 | 37 | .stack--end-row { 38 | justify-content: flex-end; 39 | } 40 | 41 | .stack--center-row { 42 | align-items: center; 43 | } 44 | 45 | .stack--center-row\@m { 46 | @media all and (min-width: $widescreen) { 47 | align-items: center; 48 | } 49 | } 50 | 51 | .stack--medium .stack__item { 52 | margin-bottom: 1rem; 53 | 54 | &:last-child { 55 | margin-bottom: 0; 56 | } 57 | } 58 | 59 | .stack--large .stack__item { 60 | margin-bottom: 2.5rem; 61 | 62 | &:last-child { 63 | margin-bottom: 0; 64 | } 65 | } 66 | 67 | .stack__item--flex { 68 | flex: 1; 69 | } 70 | 71 | .stack--split\@m.stack--medium .stack__item { 72 | @media all and (min-width: $widescreen) { 73 | margin-right: 1rem; 74 | 75 | &:last-child { 76 | margin-right: 0; 77 | } 78 | } 79 | } 80 | 81 | .stack--split\@m.stack--large .stack__item { 82 | @media all and (min-width: $widescreen) { 83 | margin-right: 2rem; 84 | 85 | &:last-child { 86 | margin-right: 0; 87 | } 88 | } 89 | } 90 | 91 | .stack--split\@m .stack__item { 92 | @media all and (min-width: $widescreen) { 93 | margin-bottom: 0; 94 | } 95 | } 96 | 97 | .stack--split\@m>.stack__item--30\@m { 98 | @media all and (min-width: $widescreen) { 99 | flex: 0 0 30%; 100 | max-width: 30%; 101 | } 102 | } 103 | 104 | .stack--split\@m>.stack__item--70\@m { 105 | @media all and (min-width: $widescreen) { 106 | flex: 0 0 70%; 107 | max-width: 70%; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /assets/css/settings/_bulma.scss: -------------------------------------------------------------------------------- 1 | $body-size: 16px; 2 | $family-sans-serif: "Nunito", sans-serif; 3 | $family-primary: "Nunito", sans-serif; 4 | $family-heading: "Nunito", sans-serif; 5 | $card-footer-border-top: 0; 6 | $radius: 0; 7 | -------------------------------------------------------------------------------- /assets/css/settings/_settings.scss: -------------------------------------------------------------------------------- 1 | // Bulma Variables 2 | $widescreen: 960px; 3 | -------------------------------------------------------------------------------- /assets/css/themes/dark.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Nunito:400,700'); 2 | @import "../../node_modules/bulmaswatch/darkly/variables"; 3 | 4 | @import "dark/colors"; 5 | 6 | $link-hover: $blue-bright; 7 | $dropdown-item-hover-color: lighten($primary, 25%); 8 | 9 | @import "../settings/bulma"; 10 | 11 | @import "../../node_modules/bulma/bulma"; 12 | @import "../../node_modules/bulmaswatch/darkly/overrides"; 13 | 14 | @import "dark/buttons"; 15 | @import "dark/card"; 16 | @import "dark/dragAndDrop"; 17 | @import "dark/dropDown"; 18 | @import "dark/form"; 19 | @import "dark/modal"; 20 | @import "dark/search"; 21 | @import "dark/tab"; 22 | @import "dark/type"; 23 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_buttons.scss: -------------------------------------------------------------------------------- 1 | .lb-button.is-primary.is-outlined { 2 | color: $blue-dull; 3 | 4 | &:hover { 5 | color: $white; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_card.scss: -------------------------------------------------------------------------------- 1 | .lb-card { 2 | box-shadow: none; 3 | border: 2px solid $dark-gray; 4 | background-color: $grey-darker; 5 | color: $white; 6 | max-width: 100%; 7 | position: relative; 8 | } 9 | 10 | .lb-card:hover { 11 | background-color: darken(#282f2f, 5%); 12 | 13 | .lb-subtitle { 14 | color: $blue-dull; 15 | } 16 | } 17 | 18 | .lb-card--clickable { 19 | &:hover { 20 | .content { 21 | color: $blue-dull; 22 | } 23 | } 24 | } 25 | 26 | .lb-card-manage__arrows { 27 | border-right: 2px solid #343c3d; 28 | } 29 | 30 | .lb-card__footer--extra { 31 | background-color: darken(#282f2f, 5%); 32 | border-top: 2px solid #343c3d; 33 | } 34 | 35 | .lb-card-footer-item { 36 | color: $grey-lighter; 37 | } 38 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_colors.scss: -------------------------------------------------------------------------------- 1 | $blue-bright: #0f96f0; 2 | $blue-dull: #afd6ff; 3 | $blue-gray: #2f4d6d; 4 | 5 | $dark-gray: #343c3d; 6 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_dragAndDrop.scss: -------------------------------------------------------------------------------- 1 | .dnd-dragging .lb-dnd-slot.active { 2 | border: 2px dashed $turquoise; 3 | } 4 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_dropDown.scss: -------------------------------------------------------------------------------- 1 | .lb-dropdown-content { 2 | background-color: $primary; 3 | padding: 0; 4 | } 5 | 6 | .lb-dropdown-item.dropdown-item { 7 | padding: 0.5rem 1rem; 8 | 9 | &:hover { 10 | background-color: $blue-gray; 11 | color: $white; 12 | } 13 | } 14 | 15 | .lb-dropdown-menu { 16 | left: auto; 17 | right: 0; 18 | } 19 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_form.scss: -------------------------------------------------------------------------------- 1 | .lb-textarea, 2 | .lb-input, 3 | .lb-select select, 4 | input::placeholder { 5 | background: transparent; 6 | color: $white !important; 7 | } 8 | 9 | .lb-radioButton .lb-radioButton__input:checked { 10 | + .lb-radioButton__label { 11 | &::before { 12 | background-color: white; 13 | box-shadow: inset 0 0 0 4px $turquoise; 14 | border-color: $turquoise; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_modal.scss: -------------------------------------------------------------------------------- 1 | .lb-modal-card-textarea { 2 | color: $white; 3 | } 4 | 5 | .lb-modal-card-close { 6 | color: $white; 7 | } 8 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_search.scss: -------------------------------------------------------------------------------- 1 | .lb-search__wrapper { 2 | background: #202525; 3 | } 4 | 5 | .lb-search__input { 6 | &::placeholder { 7 | color: #dbdee0 !important; 8 | } 9 | } 10 | 11 | .lb-search__wrapper--open { 12 | border-color: #dbdee0 !important; 13 | } 14 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_tab.scss: -------------------------------------------------------------------------------- 1 | .lb-tab.is-active { 2 | border-bottom: 2px solid $blue-bright; 3 | 4 | .lb-tab__link { 5 | color: $blue-bright; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /assets/css/themes/dark/_type.scss: -------------------------------------------------------------------------------- 1 | .lb-heading--secondary, 2 | .lb-heading--tertiary { 3 | color: $white; 4 | } 5 | 6 | // Heading/Body divider 7 | .lb-heading--divider, 8 | .lb-body--divider { 9 | border-bottom: 2px solid $white; 10 | } 11 | -------------------------------------------------------------------------------- /assets/css/themes/light.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Nunito:400,700'); 2 | @import "../../node_modules/bulmaswatch/flatly/variables"; 3 | 4 | @import "light/colors"; 5 | 6 | $link: $green; 7 | $link-hover: $primary; 8 | 9 | @import "../settings/bulma"; 10 | 11 | @import "../../node_modules/bulma/bulma"; 12 | @import "../../node_modules/bulmaswatch/flatly/overrides"; 13 | 14 | @import "light/card"; 15 | @import "light/dragAndDrop"; 16 | @import "light/dropDown"; 17 | @import "light/form"; 18 | @import "light/search"; 19 | @import "light/tab"; 20 | @import "light/type"; 21 | -------------------------------------------------------------------------------- /assets/css/themes/light/_card.scss: -------------------------------------------------------------------------------- 1 | .lb-card { 2 | border: 2px solid $grey-lighter; 3 | background-color: $white-bis; 4 | color: $primary; 5 | } 6 | 7 | .lb-card:hover { 8 | background-color: $white-ter; 9 | color: $black; 10 | 11 | .lb-subtitle { 12 | color: $black; 13 | } 14 | } 15 | 16 | .lb-card--clickable { 17 | &:hover { 18 | .content { 19 | color: $black; 20 | } 21 | } 22 | } 23 | 24 | .lb-card-manage__arrows { 25 | border-right: 2px solid $grey-lighter; 26 | } 27 | 28 | .lb-card__footer--extra { 29 | border-top: 2px solid $grey-lighter; 30 | } 31 | 32 | .lb-card-footer-item { 33 | color: $grey; 34 | } 35 | -------------------------------------------------------------------------------- /assets/css/themes/light/_colors.scss: -------------------------------------------------------------------------------- 1 | $blue-bright: #0f96f0; 2 | $blue-dull: #afd6ff; 3 | 4 | $dark-gray: #343c3d; 5 | 6 | $blue-gray: #34495e; 7 | $light-gray: #dee2e5; 8 | $green: #1d9880; 9 | -------------------------------------------------------------------------------- /assets/css/themes/light/_dragAndDrop.scss: -------------------------------------------------------------------------------- 1 | .dnd-dragging .lb-dnd-slot.active { 2 | border: 2px dashed #34495e; 3 | } 4 | -------------------------------------------------------------------------------- /assets/css/themes/light/_dropDown.scss: -------------------------------------------------------------------------------- 1 | .lb-navbar { 2 | .lb-dropdown-content { 3 | background-color: $primary; 4 | color: $white; 5 | padding: 0; 6 | } 7 | 8 | .lb-dropdown-item.dropdown-item { 9 | padding: 0.5rem 1rem; 10 | color: $white; 11 | 12 | &:hover { 13 | background-color: darken($primary, 5%); 14 | color: $white; 15 | } 16 | } 17 | } 18 | 19 | .lb-dropdown-content { 20 | background-color: $grey-lighter; 21 | color: $primary; 22 | padding: 0; 23 | } 24 | 25 | .lb-dropdown-item.dropdown-item { 26 | padding: 0.5rem 1rem; 27 | color: $primary; 28 | 29 | &:hover { 30 | background-color: darken($grey-lighter, 5%); 31 | color: $primary; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /assets/css/themes/light/_form.scss: -------------------------------------------------------------------------------- 1 | .lb-radioButton .lb-radioButton__input:checked { 2 | + .lb-radioButton__label { 3 | &::before { 4 | background-color: white; 5 | box-shadow: inset 0 0 0 4px $green; 6 | border-color: $green; 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /assets/css/themes/light/_search.scss: -------------------------------------------------------------------------------- 1 | .lb-search__wrapper { 2 | background: $white; 3 | } 4 | 5 | .lb-search__input { 6 | &::placeholder { 7 | color: $grey !important; 8 | } 9 | } 10 | 11 | .lb-search__wrapper--open { 12 | border-color: $light-gray !important; 13 | } 14 | -------------------------------------------------------------------------------- /assets/css/themes/light/_tab.scss: -------------------------------------------------------------------------------- 1 | .lb-tab.is-active { 2 | border-bottom: 2px solid $primary; 3 | 4 | .lb-tab__link { 5 | color: $primary; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /assets/css/themes/light/_type.scss: -------------------------------------------------------------------------------- 1 | .lb-heading--secondary, 2 | .lb-heading--tertiary { 3 | color: $primary; 4 | } 5 | 6 | // Heading/Body divider 7 | .lb-heading--divider, 8 | .lb-body--divider { 9 | border-bottom: 2px solid $primary; 10 | } 11 | -------------------------------------------------------------------------------- /assets/css/utilities/_spacing.scss: -------------------------------------------------------------------------------- 1 | $space-s: 0.5em !important; 2 | $space-m: 1em !important; 3 | $space-l: 2em !important; 4 | $space-h: 3em !important; 5 | 6 | .u-Ptn, 7 | .u-Pvn, 8 | .u-Pan { 9 | padding-top: 0 !important; 10 | } 11 | 12 | .u-Pts, 13 | .u-Pvs, 14 | .u-Pas { 15 | padding-top: $space-s; 16 | } 17 | 18 | .u-Ptm, 19 | .u-Pvm, 20 | .u-Pam { 21 | padding-top: $space-m; 22 | } 23 | 24 | .u-Ptl, 25 | .u-Pvl, 26 | .u-Pal { 27 | padding-top: $space-l; 28 | } 29 | 30 | .u-Pth, 31 | .u-Pvh, 32 | .u-Pah { 33 | padding-top: $space-h; 34 | } 35 | 36 | .u-Prn, 37 | .u-Phn, 38 | .u-Pan { 39 | padding-right: 0 !important; 40 | } 41 | 42 | .u-Prs, 43 | .u-Phs, 44 | .u-Pas { 45 | padding-right: $space-s; 46 | } 47 | 48 | .u-Prm, 49 | .u-Phm, 50 | .u-Pam { 51 | padding-right: $space-m; 52 | } 53 | 54 | .u-Prl, 55 | .u-Phl, 56 | .u-Pal { 57 | padding-right: $space-l; 58 | } 59 | 60 | .u-Prh, 61 | .u-Phh, 62 | .u-Pah { 63 | padding-right: $space-h; 64 | } 65 | 66 | .u-Pbn, 67 | .u-Pvn, 68 | .u-Pan { 69 | padding-bottom: 0 !important; 70 | } 71 | 72 | .u-Pbs, 73 | .u-Pvs, 74 | .u-Pas { 75 | padding-bottom: $space-s; 76 | } 77 | 78 | .u-Pbm, 79 | .u-Pvm, 80 | .u-Pam { 81 | padding-bottom: $space-m; 82 | } 83 | 84 | .u-Pbl, 85 | .u-Pvl, 86 | .u-Pal { 87 | padding-bottom: $space-l; 88 | } 89 | 90 | .u-Pbh, 91 | .u-Pvh, 92 | .u-Pah { 93 | padding-bottom: $space-h; 94 | } 95 | 96 | .u-Pln, 97 | .u-Phn, 98 | .u-Pan { 99 | padding-left: 0 !important; 100 | } 101 | 102 | .u-Pls, 103 | .u-Phs, 104 | .u-Pas { 105 | padding-left: $space-s; 106 | } 107 | 108 | .u-Plm, 109 | .u-Phm, 110 | .u-Pam { 111 | padding-left: $space-m; 112 | } 113 | 114 | .u-Pll, 115 | .u-Phl, 116 | .u-Pal { 117 | padding-left: $space-l; 118 | } 119 | 120 | .u-Plh, 121 | .u-Phh, 122 | .u-Pah { 123 | padding-left: $space-h; 124 | } 125 | 126 | .u-Mtn, 127 | .u-Mvn, 128 | .u-Man { 129 | margin-top: 0 !important; 130 | } 131 | 132 | .u-Mts, 133 | .u-Mvs, 134 | .u-Mas { 135 | margin-top: $space-s; 136 | } 137 | 138 | .u-Mtm, 139 | .u-Mvm, 140 | .u-Mam { 141 | margin-top: $space-m; 142 | } 143 | 144 | .u-Mtl, 145 | .u-Mvl, 146 | .u-Mal { 147 | margin-top: $space-l; 148 | } 149 | 150 | .u-Mth, 151 | .u-Mvh, 152 | .u-Mah { 153 | margin-top: $space-h; 154 | } 155 | 156 | .u-Mrn, 157 | .u-Mhn, 158 | .u-Man { 159 | margin-right: 0 !important; 160 | } 161 | 162 | .u-Mrs, 163 | .u-Mhs, 164 | .u-Mas { 165 | margin-right: $space-s; 166 | } 167 | 168 | .u-Mrm, 169 | .u-Mhm, 170 | .u-Mam { 171 | margin-right: $space-m; 172 | } 173 | 174 | .u-Mrl, 175 | .u-Mhl, 176 | .u-Mal { 177 | margin-right: $space-l; 178 | } 179 | 180 | .u-Mrh, 181 | .u-Mhh, 182 | .u-Mah { 183 | margin-right: $space-h; 184 | } 185 | 186 | .u-Mbn, 187 | .u-Mvn, 188 | .u-Man { 189 | margin-bottom: 0 !important; 190 | } 191 | 192 | .u-Mbs, 193 | .u-Mvs, 194 | .u-Mas { 195 | margin-bottom: $space-s; 196 | } 197 | 198 | .u-Mbm, 199 | .u-Mvm, 200 | .u-Mam { 201 | margin-bottom: $space-m; 202 | } 203 | 204 | .u-Mbl, 205 | .u-Mvl, 206 | .u-Mal { 207 | margin-bottom: $space-l; 208 | } 209 | 210 | .u-Mbh, 211 | .u-Mvh, 212 | .u-Mah { 213 | margin-bottom: $space-h; 214 | } 215 | 216 | .u-Mln, 217 | .u-Mhn, 218 | .u-Man { 219 | margin-left: 0 !important; 220 | } 221 | 222 | .u-Mls, 223 | .u-Mhs, 224 | .u-Mas { 225 | margin-left: $space-s; 226 | } 227 | 228 | .u-Mlm, 229 | .u-Mhm, 230 | .u-Mam { 231 | margin-left: $space-m; 232 | } 233 | 234 | .u-Mll, 235 | .u-Mhl, 236 | .u-Mal { 237 | margin-left: $space-l; 238 | } 239 | 240 | .u-Mlh, 241 | .u-Mhh, 242 | .u-Mah { 243 | margin-left: $space-h; 244 | } 245 | -------------------------------------------------------------------------------- /assets/css/utilities/_utilities.scss: -------------------------------------------------------------------------------- 1 | .u-Width--100 { 2 | width: 100%; 3 | } 4 | 5 | .u-Height--100 { 6 | height: 100%; 7 | } 8 | 9 | .u-PostionRelative { 10 | position: relative; 11 | } 12 | 13 | .color-limegreen { 14 | color: limegreen; 15 | } 16 | .u-DisplayBlock { 17 | display: block; 18 | } 19 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import css from '../css/app.scss'; 5 | 6 | import hamburger from './hamburger'; 7 | import dnd from './dnd'; 8 | import {datalistHelper} from './datalist_helper'; 9 | 10 | window.dnd = dnd; 11 | window.datalistHelper = datalistHelper; 12 | 13 | // webpack automatically bundles all modules in your 14 | // entry points. Those entry points can be configured 15 | // in 'webpack.config.js'. 16 | // 17 | // Import dependencies 18 | // 19 | // import 'phoenix_html' 20 | 21 | // Import local files 22 | // 23 | // Local files can be imported directly using relative paths, for example: 24 | // import socket from './socket' 25 | 26 | import LiveSocket from 'phoenix_live_view'; 27 | 28 | let liveSocket = new LiveSocket('/live'); 29 | liveSocket.connect(); 30 | 31 | document.body.addEventListener('click', function(e) { 32 | let target = e.target; 33 | 34 | // Focus and alter height of textarea if they click in the card or add a card 35 | if (target.classList.contains('js-inlineEdit') || target.classList.contains('js-addCard')) { 36 | setTimeout(() => { 37 | const textarea = document.getElementById('txtarea'); 38 | 39 | textarea.focus(); 40 | textarea.setSelectionRange(textarea.value.length, textarea.value.length); 41 | textarea.setAttribute('style', 'height:' + (textarea.scrollHeight) + 'px; overflow-y: hidden;'); 42 | textarea.addEventListener('input', OnInput, false); 43 | 44 | function OnInput(e) { 45 | this.style.height = 'auto'; 46 | this.style.height = (this.scrollHeight - 15) + 'px'; 47 | } 48 | }, 300); 49 | } 50 | 51 | // Focus and alter height of textarea if they click on card modal 52 | if (target.classList.contains('js-cardModal')) { 53 | setTimeout(() => { 54 | const modalTextarea = document.querySelector('.lb-modal-card-textarea'); 55 | 56 | modalTextarea.focus(); 57 | modalTextarea.setSelectionRange(modalTextarea.value.length, modalTextarea.value.length); 58 | modalTextarea.setAttribute('style', 'height:' + (modalTextarea.scrollHeight) + 'px; overflow-y: hidden;'); 59 | modalTextarea.addEventListener('input', OnInput, false); 60 | 61 | function OnInput(e) { 62 | this.style.height = '1px'; 63 | this.style.height = (this.scrollHeight) + 'px'; 64 | } 65 | }, 300); 66 | } 67 | 68 | if (target.classList.contains('js-inputTrigger')) { 69 | setTimeout(() => { 70 | const inputAlt = document.querySelector('.lb-input--alt'); 71 | 72 | inputAlt.focus(); 73 | inputAlt.setSelectionRange(inputAlt.value.length, inputAlt.value.length); 74 | }, 300); 75 | } 76 | }); 77 | 78 | // Only run this is js-search is on the page 79 | if (document.querySelector('input').classList.contains('js-search')) { 80 | document.getElementById('lb-search').addEventListener('submit', (e) => { 81 | e.preventDefault(); 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /assets/js/cyborg.js: -------------------------------------------------------------------------------- 1 | // We need to import the CSS so that webpack will load it. 2 | // The MiniCssExtractPlugin is used to separate it out into 3 | // its own CSS file. 4 | import css from "../css/cyborg.scss" 5 | 6 | import hamburger from "./hamburger" 7 | 8 | // webpack automatically bundles all modules in your 9 | // entry points. Those entry points can be configured 10 | // in "webpack.config.js". 11 | // 12 | // Import dependencies 13 | // 14 | // import "phoenix_html" 15 | 16 | // Import local files 17 | // 18 | // Local files can be imported directly using relative paths, for example: 19 | // import socket from "./socket" 20 | -------------------------------------------------------------------------------- /assets/js/datalist_helper.js: -------------------------------------------------------------------------------- 1 | export const datalistHelper = { 2 | roleUpdate: (event) => { 3 | const input = event.target; 4 | const label = input.value; 5 | const dataListOptionId = input.getAttribute('list'); 6 | const options = document.querySelectorAll(`#${dataListOptionId} option`); 7 | 8 | for (let option in options) { 9 | if (options[option] && options[option].innerText === label) { 10 | window.roleValue = options[option].dataset.value; 11 | break; 12 | } 13 | } 14 | }, 15 | 16 | addIdOnSubmit: (event) => { 17 | document.getElementById('userId').value = window.roleValue; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /assets/js/dnd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For drag and dropping of cards and piles 3 | */ 4 | 5 | const bodyClassWhenDragging = 'dnd-dragging'; 6 | 7 | // Utility (non-exported) function for finding a data attribute in a parent element 8 | const findDataFromParent = function findDataFromParent(el, dataKey) { 9 | if (el.dataset[dataKey]) { 10 | return el.dataset[dataKey]; 11 | } else if (el.parentElement) { 12 | return findDataFromParent(el.parentElement, dataKey); 13 | } 14 | }; 15 | 16 | const handleDrop = function drop(ev, pathPart, data) { 17 | ev.preventDefault(); 18 | document.body.classList.remove(bodyClassWhenDragging); 19 | const boardId = document.querySelector('meta[name=board_id]').getAttribute('content'); 20 | 21 | const request = new XMLHttpRequest(); 22 | request.open('POST', `/boards/${boardId}/${pathPart}`, true); 23 | request.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); 24 | request.setRequestHeader('X-CSRF-Token', document.querySelector('meta[name=csrf]').content); 25 | request.send(JSON.stringify(data)); 26 | }; 27 | 28 | const drag = function drag(ev) { 29 | ev.dataTransfer.setData('cardId', findDataFromParent(ev.target, 'cardId')); 30 | ev.dataTransfer.setData('pileId', findDataFromParent(ev.target, 'pileId')); 31 | ev.dataTransfer.dropEffect = 'copy'; 32 | 33 | document.body.classList.add(bodyClassWhenDragging); 34 | }; 35 | 36 | const allowDrop = function allowDrop(ev) { 37 | ev.target.classList.add('active'); 38 | ev.preventDefault(); 39 | }; 40 | 41 | const dragLeave = function dragLeave(ev) { 42 | ev.target.classList.remove('active'); 43 | }; 44 | 45 | const dropIntoPile = function dropIntoPile(ev) { 46 | cardId = ev.dataTransfer.getData('cardId'); 47 | pileId = ev.dataTransfer.getData('pileId'); 48 | 49 | handleDrop(ev, 'dnd-into-pile', { 50 | what: cardId === 'undefined' ? 'pile' : 'card', 51 | what_id: cardId === 'undefined' ? pileId : cardId, 52 | pile_id: findDataFromParent(ev.target, 'pileId'), 53 | }); 54 | }; 55 | 56 | const dropIntoJunction = function dropIntoJunction(ev) { 57 | cardId = ev.dataTransfer.getData('cardId'); 58 | pileId = ev.dataTransfer.getData('pileId'); 59 | 60 | handleDrop(ev, 'dnd-into-junction', { 61 | what: cardId === 'undefined' ? 'pile' : 'card', 62 | what_id: cardId === 'undefined' ? pileId : cardId, 63 | col_id: findDataFromParent(ev.target, 'colId'), 64 | pos: findDataFromParent(ev.target, 'pos'), 65 | }); 66 | }; 67 | 68 | module.exports = { drag, dragLeave, allowDrop, dropIntoPile, dropIntoJunction }; -------------------------------------------------------------------------------- /assets/js/hamburger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * For the Hamburger menu, per Bulma's docs. 3 | * https://bulma.io/documentation/components/navbar/ 4 | */ 5 | document.addEventListener('DOMContentLoaded', () => { 6 | const $navbarBurgers = 7 | Array.prototype.slice.call(document.querySelectorAll('.navbar-burger'), 0); 8 | 9 | const $usermenu = document.getElementById('usermenu'); 10 | 11 | // Check if there are any navbar burgers 12 | if ($navbarBurgers.length > 0) { 13 | 14 | // Add a click event on each of them 15 | $navbarBurgers.forEach( el => { 16 | el.addEventListener('click', () => { 17 | 18 | // Get the target from the "data-target" attribute 19 | const target = el.dataset.target; 20 | const $target = document.getElementById(target); 21 | 22 | // Toggle the "is-active" class on both the "navbar-burger" and the 23 | // "navbar-menu" 24 | el.classList.toggle('is-active'); 25 | $target.classList.toggle('is-active'); 26 | 27 | // Swap Lucidboard's dropdown user menu since it opens the other way in 28 | // the hamburger 29 | $usermenu.classList.toggle('is-left'); 30 | $usermenu.classList.toggle('is-right'); 31 | }); 32 | }); 33 | } 34 | 35 | }); -------------------------------------------------------------------------------- /assets/js/socket.js: -------------------------------------------------------------------------------- 1 | // NOTE: The contents of this file will only be executed if 2 | // you uncomment its entry in "assets/js/app.js". 3 | 4 | // To use Phoenix channels, the first step is to import Socket, 5 | // and connect at the socket path in "lib/web/endpoint.ex". 6 | // 7 | // Pass the token on params as below. Or remove it 8 | // from the params if you are not using authentication. 9 | import {Socket} from "phoenix" 10 | 11 | let socket = new Socket("/socket", {params: {token: window.userToken}}) 12 | 13 | // When you connect, you'll often need to authenticate the client. 14 | // For example, imagine you have an authentication plug, `MyAuth`, 15 | // which authenticates the session and assigns a `:current_user`. 16 | // If the current user exists you can assign the user's token in 17 | // the connection for use in the layout. 18 | // 19 | // In your "lib/web/router.ex": 20 | // 21 | // pipeline :browser do 22 | // ... 23 | // plug MyAuth 24 | // plug :put_user_token 25 | // end 26 | // 27 | // defp put_user_token(conn, _) do 28 | // if current_user = conn.assigns[:current_user] do 29 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id) 30 | // assign(conn, :user_token, token) 31 | // else 32 | // conn 33 | // end 34 | // end 35 | // 36 | // Now you need to pass this token to JavaScript. You can do so 37 | // inside a script tag in "lib/web/templates/layout/app.html.eex": 38 | // 39 | // 40 | // 41 | // You will need to verify the user token in the "connect/3" function 42 | // in "lib/web/channels/user_socket.ex": 43 | // 44 | // def connect(%{"token" => token}, socket, _connect_info) do 45 | // # max_age: 1209600 is equivalent to two weeks in seconds 46 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do 47 | // {:ok, user_id} -> 48 | // {:ok, assign(socket, :user, user_id)} 49 | // {:error, reason} -> 50 | // :error 51 | // end 52 | // end 53 | // 54 | // Finally, connect to the socket: 55 | socket.connect() 56 | 57 | // Now that you are connected, you can join channels with a topic: 58 | let channel = socket.channel("topic:subtopic", {}) 59 | channel.join() 60 | .receive("ok", resp => { console.log("Joined successfully", resp) }) 61 | .receive("error", resp => { console.log("Unable to join", resp) }) 62 | 63 | export default socket 64 | -------------------------------------------------------------------------------- /assets/ops/config.fish: -------------------------------------------------------------------------------- 1 | # This first condition ensures the echo doesn't happen twice. 2 | # I have no idea why this file is being executed twice!! 3 | if [ ! "$HEX_UNSAFE_HTTPS" -a "$QL_MODE" -eq "1" ] 4 | echo "Disabling SSL authentication in Hex for operation on the QL network." 5 | set -x HEX_UNSAFE_HTTPS 1 6 | end 7 | 8 | alias imp "iex -S mix phx.server" 9 | alias im "iex -S mix" 10 | alias mdg "mix deps.get" 11 | alias mdu "mix deps.update --all" 12 | alias mt "mix test" 13 | alias mtw "mix test.watch" 14 | alias ml "mix lint" 15 | alias mer "mix ecto.reset" 16 | 17 | function setup 18 | mix deps.get; and \ 19 | cd assets; npm install; and cd ..; \ 20 | mix ecto.setup; and \ 21 | echo "You may now start the dev server with `imp`." 22 | end 23 | 24 | alias gff "git pull --rebase origin $argv" 25 | alias ga "git add $argv" 26 | alias gp "git push $argv" 27 | alias gl "git log $argv" 28 | alias gs "git status --ignore-submodules $argv" 29 | alias gsu "git submodule $argv" 30 | alias gsu-init-recursive-update "gsu update --init --recursive" 31 | alias gd "git diff $argv" 32 | alias gm "git commit -m $argv" 33 | alias gb "git branch $argv" 34 | alias gbr "git branch -r $argv" 35 | alias gc "git checkout $argv" 36 | alias gre "git rebase $argv" 37 | alias gf "git fetch -p $argv" 38 | alias grm "git rm $argv" 39 | alias gmv "git mv $argv" 40 | alias grv "git remote -v $argv" 41 | alias gca "git commit --amend" 42 | -------------------------------------------------------------------------------- /assets/ops/dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:latest 2 | 3 | ARG QL_MODE 4 | ENV QL_MODE=$QL_MODE 5 | 6 | COPY setup_security.sh / 7 | RUN /setup_security.sh 8 | 9 | RUN apt-get update 10 | RUN apt-get install --yes build-essential inotify-tools postgresql-client fish vim 11 | 12 | # Install Phoenix packages 13 | RUN mix local.hex --force 14 | RUN mix local.rebar --force 15 | RUN mix archive.install --force https://github.com/phoenixframework/archives/raw/master/phx_new.ez 16 | 17 | WORKDIR /app 18 | EXPOSE 8800 19 | 20 | COPY config.fish /root/.config/fish/config.fish 21 | 22 | ENTRYPOINT fish 23 | -------------------------------------------------------------------------------- /assets/ops/dev/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | 3 | services: 4 | db: 5 | container_name: lucidboard_dev_db 6 | restart: always 7 | image: postgres 8 | volumes: [ "lucidboard_dev_db:/var/lib/postgresql/data" ] 9 | ports: [ "5432:5432" ] 10 | environment: 11 | POSTGRES_PASSWORD: verysecure123 12 | networks: 13 | - lnet 14 | 15 | node: 16 | container_name: lucidboard_node 17 | restart: always 18 | image: node:12 19 | stdin_open: true 20 | tty: true 21 | volumes: 22 | - node_bin:/usr/local/bin 23 | - node_modules:/usr/local/lib/node_modules 24 | networks: 25 | - lnet 26 | 27 | app: 28 | container_name: lucidboard_dev 29 | restart: always 30 | build: 31 | context: .. 32 | dockerfile: dev/Dockerfile 33 | args: { QL_MODE: "${QL_MODE}" } 34 | tty: true 35 | stdin_open: true 36 | depends_on: [ db ] 37 | volumes: 38 | - '../../..:/app' 39 | - node_bin:/usr/local/njs 40 | - node_modules:/usr/local/lib/node_modules 41 | ports: [ "8800:8800" ] 42 | environment: 43 | PG_HOST: db 44 | PATH: /usr/local/njs:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 45 | networks: 46 | - lnet 47 | 48 | volumes: 49 | node_bin: 50 | node_modules: 51 | lucidboard_dev_db: 52 | 53 | networks: 54 | lnet: 55 | -------------------------------------------------------------------------------- /assets/ops/release/Dockerfile: -------------------------------------------------------------------------------- 1 | # The version of Alpine to use for the final image 2 | # This should match the version of Alpine that the elixir image uses 3 | ARG ALPINE_VERSION=3.9 4 | 5 | FROM elixir:1.9.1-alpine AS builder 6 | 7 | ARG QL_MODE 8 | ENV QL_MODE=$QL_MODE 9 | 10 | # The following are build arguments used to change variable parts of the image. 11 | # The name of your application/release (required) 12 | ARG APP_NAME 13 | # The version of the application we are building (required) 14 | ARG APP_VSN 15 | # The environment to build with 16 | ARG MIX_ENV=prod 17 | # Set this to true if this release is not a Phoenix app 18 | ARG SKIP_PHOENIX=false 19 | # If you are using an umbrella project, you can change this 20 | # argument to the directory the Phoenix app is in so that the assets 21 | # can be built 22 | ARG PHOENIX_SUBDIR=. 23 | 24 | ENV APP_NAME=${APP_NAME} \ 25 | APP_VSN=${APP_VSN} \ 26 | MIX_ENV=${MIX_ENV} \ 27 | HEX_UNSAFE_HTTPS=${QL_MODE} 28 | 29 | # By convention, /opt is typically used for applications 30 | WORKDIR /opt/app 31 | 32 | # This step installs all the build tools we'll need 33 | RUN apk update && \ 34 | apk upgrade --no-cache && \ 35 | apk add --no-cache \ 36 | nodejs \ 37 | yarn \ 38 | git \ 39 | curl \ 40 | build-base && \ 41 | mix local.rebar --force && \ 42 | mix local.hex --force 43 | 44 | COPY assets/ops/setup_security.sh / 45 | RUN /setup_security.sh 46 | 47 | # This copies our app source code into the build container 48 | COPY . . 49 | 50 | RUN mix do deps.get, deps.compile, compile 51 | 52 | # This step builds assets for the Phoenix app (if there is one) 53 | # If you aren't building a Phoenix app, pass `--build-arg SKIP_PHOENIX=true` 54 | # This is mostly here for demonstration purposes 55 | RUN cd ${PHOENIX_SUBDIR}/assets && \ 56 | yarn install && \ 57 | yarn deploy && \ 58 | cd .. && \ 59 | mix phx.digest 60 | 61 | RUN \ 62 | mkdir -p /opt/built && \ 63 | mix release lucidboard && \ 64 | cp -a _build/${MIX_ENV}/rel/${APP_NAME}/* /opt/built && \ 65 | cd /opt/built 66 | # cp _build/${MIX_ENV}/rel/${APP_NAME}/releases/${APP_VSN}/${APP_NAME}.tar.gz /opt/built && \ 67 | # cd /opt/built && \ 68 | # tar -xzf ${APP_NAME}.tar.gz && \ 69 | # rm ${APP_NAME}.tar.gz 70 | 71 | # From this line onwards, we're in a new image, which will be the image used in production 72 | FROM alpine:${ALPINE_VERSION} 73 | 74 | # The name of your application/release (required) 75 | ARG APP_NAME 76 | 77 | RUN apk update && \ 78 | apk add --no-cache bash openssl-dev 79 | 80 | ENV APP_NAME=${APP_NAME} 81 | 82 | WORKDIR /opt/app 83 | 84 | COPY --from=builder /opt/built . 85 | 86 | CMD trap 'exit' INT; \ 87 | /opt/app/bin/${APP_NAME} eval "Lucidboard.Release.migrate()"; \ 88 | /opt/app/bin/${APP_NAME} start 89 | -------------------------------------------------------------------------------- /assets/ops/setup_security.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ "$QL_MODE" != "1" ]; then 4 | exit 5 | fi 6 | 7 | echo "Adding QL Certificate as Trusted..." 8 | 9 | CERT_FILE=Quicken_Loans_Root_CA.crt 10 | CERT=https://git.rockfin.com/raw/SKluck/docker-images/master/.shared/certificates/$CERT_FILE 11 | CERT_DIR=/etc/ssl/certs 12 | 13 | curl -sSl -o $CERT_DIR/$CERT_FILE $CERT 14 | 15 | echo cacert=/etc/ssl/certs/Quicken_Loans_Root_CA.crt > ~/.curlrc 16 | 17 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "deploy": "webpack --mode production", 6 | "watch": "webpack --mode development --watch" 7 | }, 8 | "dependencies": { 9 | "bulma": "^0.7.2", 10 | "phoenix": "file:../deps/phoenix", 11 | "phoenix_html": "file:../deps/phoenix_html" 12 | }, 13 | "devDependencies": { 14 | "@babel/core": "^7.5.5", 15 | "@babel/preset-env": "^7.5.5", 16 | "@fortawesome/fontawesome-free": "^5.8.2", 17 | "babel-loader": "^8.0.6", 18 | "bulmaswatch": "^0.7.2", 19 | "copy-webpack-plugin": "^4.5.0", 20 | "css-loader": "^0.28.10", 21 | "file-loader": "^3.0.1", 22 | "mini-css-extract-plugin": "^0.4.0", 23 | "node-sass": "^4.12.0", 24 | "optimize-css-assets-webpack-plugin": "^4.0.0", 25 | "phoenix_live_view": "file:../deps/phoenix_live_view", 26 | "sass-loader": "^7.1.0", 27 | "uglifyjs-webpack-plugin": "^1.2.4", 28 | "webpack": "4.4.0", 29 | "webpack-cli": "^2.0.10" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djthread/lucidboard/f549381b7f0bd0d0ca63dd14123c3e0332054a09/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/fonts/Arsenal-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/djthread/lucidboard/f549381b7f0bd0d0ca63dd14123c3e0332054a09/assets/static/fonts/Arsenal-Regular.ttf -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /assets/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const glob = require('glob'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin'); 6 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 7 | 8 | module.exports = (env, options) => ({ 9 | optimization: { 10 | minimizer: [ 11 | new UglifyJsPlugin({ cache: true, parallel: true, sourceMap: false }), 12 | new OptimizeCSSAssetsPlugin({}), 13 | ], 14 | }, 15 | entry: { 16 | 'app': ['./js/app.js'].concat(glob.sync('./vendor/**/*.js')), 17 | 'light': ['./css/themes/light.scss'], 18 | 'dark': ['./css/themes/dark.scss'], 19 | }, 20 | output: { 21 | filename: '[name].js', 22 | path: path.resolve(__dirname, '../priv/static/js'), 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.js$/, 28 | exclude: /node_modules/, 29 | use: { 30 | loader: 'babel-loader', 31 | }, 32 | }, 33 | { 34 | test: /\.scss$/, 35 | use: [ 36 | MiniCssExtractPlugin.loader, 37 | { loader: 'css-loader', options: {} }, 38 | { loader: 'sass-loader', options: {} }, 39 | ], 40 | }, 41 | { 42 | test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/, 43 | use: [{ 44 | loader: 'file-loader', 45 | options: { 46 | name: '[name].[ext]', 47 | outputPath: '../fonts/', 48 | } 49 | }] 50 | }, 51 | ], 52 | }, 53 | plugins: [ 54 | new MiniCssExtractPlugin({ 55 | filename: '../css/[name].css', 56 | }), 57 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }]), 58 | ], 59 | }); 60 | -------------------------------------------------------------------------------- /bin/db: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Starts the Postgres container only (with port 5432 open). Use this if you 4 | # prefer to use your system-installed instance of Elixir. 5 | # 6 | # Use dev to start it *with* the elixir container. 7 | # 8 | # Steps: 9 | # 10 | # 1. Write secret files if they don't already exist 11 | # 2. Start postgres 12 | # 13 | 14 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 15 | 16 | if "$DIR/maybe_create_secret_files"; then 17 | cat << EOF 18 | Elixir development environment initialized! 19 | 20 | To install dependencies and set up the database, run the following commands: 21 | 22 | mix deps.get 23 | cd assets; npm install; cd .. 24 | mix ecto.setup 25 | 26 | EOF 27 | fi 28 | 29 | docker-compose \ 30 | -p lucidboard_dev \ 31 | -f "$DIR/../assets/ops/dev/docker-compose.yml" \ 32 | run -d \ 33 | --name lucidboard_dev_db \ 34 | -p 5432:5432 \ 35 | db && \ 36 | echo "Postgres started. Listening on localhost:5432." 37 | -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Lucidboard docker-based dev environment starter 4 | # 5 | # 1. Write secret files if they don't already exist 6 | # 2. docker-compose up -d (runs postgres and elixir containers) 7 | # 3. Run fish, a friendly shell 8 | # 9 | # Note that if you have Elixir installed to your system, you may like to run 10 | # only postgres as a container. In this case, `db` can be used instead of 11 | # this script. (`down` will still work.) 12 | # 13 | 14 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 15 | 16 | if [ "$1" == "--ql" ]; then 17 | export QL_MODE=1 18 | ARGS="${@:2}" 19 | else 20 | ARGS="$@" 21 | fi 22 | 23 | if "$DIR/maybe_create_secret_files"; then 24 | cat << EOF 25 | Elixir development environment initialized! 26 | 27 | To install dependencies and set up the database, run the following commanads: 28 | 29 | mix deps.get 30 | cd assets; npm install; cd .. 31 | mix ecto.setup 32 | 33 | (Or execute these commands with the shortcut - \`setup\`) 34 | 35 | EOF 36 | fi 37 | 38 | docker-compose \ 39 | -p lucidboard_dev \ 40 | -f "$DIR/../assets/ops/dev/docker-compose.yml" \ 41 | up $ARGS -d && \ 42 | docker exec -it lucidboard_dev fish 43 | -------------------------------------------------------------------------------- /bin/down: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Stops all our docker containers. Works with `dev` or `db` scripts. 4 | # 5 | 6 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 7 | 8 | docker-compose \ 9 | -p lucidboard_dev \ 10 | -f "$DIR/../assets/ops/dev/docker-compose.yml" \ 11 | down 12 | -------------------------------------------------------------------------------- /bin/dp: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Our wrapper for `docker-compose` 4 | # 5 | 6 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 7 | 8 | docker-compose \ 9 | -p lucidboard_dev \ 10 | -f "$DIR/../assets/ops/dev/docker-compose.yml" \ 11 | $@ 12 | -------------------------------------------------------------------------------- /bin/maybe_create_secret_files: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Writes `dev.secret.exs` and `test.secret.exs` if they don't already exist. 4 | # 5 | # Exits with 0 if the files are both written. 1 if not. 6 | # 7 | 8 | DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 9 | DEVSECRETFILE="$DIR/../config/dev.secret.exs" 10 | TESTSECRETFILE="$DIR/../config/test.secret.exs" 11 | 12 | if [ ! -f "$DEVSECRETFILE" ]; then 13 | cat << EOF > "$DEVSECRETFILE" 14 | use Mix.Config 15 | 16 | config :lucidboard, Lucidboard.Repo, 17 | adapter: Ecto.Adapters.Postgres, 18 | username: System.get_env("PG_USER") || "postgres", 19 | password: System.get_env("PG_PASS") || "verysecure123", 20 | database: System.get_env("PG_DB") || "lucidboard_dev", 21 | hostname: System.get_env("PG_HOST") || "localhost", 22 | pool_size: 10 23 | EOF 24 | 25 | if [ ! -f "$TESTSECRETFILE" ]; then 26 | cp "$DEVSECRETFILE" "$TESTSECRETFILE" 27 | exit 0 28 | fi 29 | fi 30 | 31 | exit 1 32 | -------------------------------------------------------------------------------- /bin/psql: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Shortcut to start postgres cli in its running docker container. 4 | # 5 | # Be sure and start postgres first with `db.sh`. 6 | # 7 | 8 | docker exec -it lucidboard_dev_db psql -U postgres 9 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | use Mix.Config 7 | 8 | # General application configuration 9 | config :lucidboard, 10 | ecto_repos: [Lucidboard.Repo] 11 | 12 | # Configures the endpoint 13 | config :lucidboard, LucidboardWeb.Endpoint, 14 | url: [host: "localhost"], 15 | secret_key_base: 16 | "ynHoNC75BVRedbPP06+hVh6fj9+J2vP+K51G0J9F7xeeqYXSMpHJ4cYT1N70Qqlw", 17 | render_errors: [view: LucidboardWeb.ErrorView, accepts: ~w(html json)], 18 | pubsub: [name: Lucidboard.PubSub, adapter: Phoenix.PubSub.PG2], 19 | live_view: [ 20 | signing_salt: "OcXvrFwwOpyqvo+oCIbpdeEdOKmvt3zs" 21 | ] 22 | 23 | config :phoenix, 24 | template_engines: [leex: Phoenix.LiveView.Engine] 25 | 26 | config :lucidboard, :templates, [ 27 | %{ 28 | name: "Retrospective", 29 | columns: ["What Went Well", "What Didn't Go Well", "Propouts"], 30 | settings: [likes_per_user: 5, likes_per_user_per_card: 3] 31 | }, 32 | %{ 33 | name: "Lean Coffee", 34 | columns: ["Ready", "Doing", "Done"], 35 | settings: [likes_per_user: 2, likes_per_user_per_card: 2] 36 | } 37 | ] 38 | 39 | # Options are :dumb, :github, :pingfed. See documentation about authentication 40 | config :lucidboard, :auth_provider, :dumb 41 | 42 | config :lucidboard, :default_theme, "dark" 43 | 44 | config :lucidboard, :themes, ~w(light dark) 45 | 46 | config :lucidboard, :timezone, "America/Detroit" 47 | 48 | config :phoenix, :json_library, Jason 49 | 50 | # Configures Elixir's Logger 51 | config :logger, :console, 52 | format: "$time $metadata[$level] $message\n", 53 | metadata: [:user_id] 54 | 55 | config :oauth2, serializers: %{"application/json" => Jason} 56 | 57 | config :ueberauth, Ueberauth, 58 | providers: [ 59 | github: {Ueberauth.Strategy.Github, default_scope: "user:email"}, 60 | pingfed: {Ueberauth.Strategy.PingFed, default_scope: "openid profile"} 61 | ] 62 | 63 | # config :ueberauth, Ueberauth.Strategy.Github.OAuth 64 | # site: "https://git.rockfin.com", 65 | # authorize_url: "https://git.rockfin.com/login/oauth/authorize", 66 | # token_url: "https://git.rockfin.com/login/oauth/access_token" 67 | 68 | # Import environment specific config. This must remain at the bottom 69 | # of this file so it overrides the configuration defined above. 70 | import_config "#{Mix.env()}.exs" 71 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with brunch.io to recompile .js and .css sources. 9 | config( 10 | :lucidboard, 11 | LucidboardWeb.Endpoint, 12 | [ 13 | http: [port: 8800], 14 | debug_errors: true, 15 | code_reloader: true, 16 | check_origin: false 17 | ] ++ 18 | if System.get_env("NO_WEBPACK_WATCH") do 19 | [] 20 | else 21 | [ 22 | watchers: [ 23 | node: [ 24 | "node_modules/webpack/bin/webpack.js", 25 | "--mode", 26 | "development", 27 | "--watch-stdin", 28 | cd: Path.expand("../assets", __DIR__) 29 | ] 30 | ] 31 | ] 32 | end 33 | ) 34 | 35 | # ## SSL Support 36 | # 37 | # In order to use HTTPS in development, a self-signed 38 | # certificate can be generated by running the following 39 | # Mix task: 40 | # 41 | # mix phx.gen.cert 42 | # 43 | # Note that this task requires Erlang/OTP 20 or later. 44 | # Run `mix help phx.gen.cert` for more information. 45 | # 46 | # The `http:` config above can be replaced with: 47 | # 48 | # https: [ 49 | # port: 4001, 50 | # cipher_suite: :strong, 51 | # keyfile: "priv/cert/selfsigned_key.pem", 52 | # certfile: "priv/cert/selfsigned.pem" 53 | # ], 54 | # 55 | # If desired, both `http:` and `https:` keys can be 56 | # configured to run both http and https servers on 57 | # different ports. 58 | 59 | # Watch static and templates for browser reloading. 60 | config :lucidboard, LucidboardWeb.Endpoint, 61 | live_reload: [ 62 | patterns: [ 63 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$}, 64 | ~r{priv/gettext/.*(po)$}, 65 | ~r{lib/lucidboard_web/views/.*(ex)$}, 66 | ~r{lib/lucidboard_web/templates/.*(eex)$}, 67 | ~r{lib/lucidboard_web/controllers/.*(ex)$}, 68 | ~r{lib/demo_web/live/.*(ex)$} 69 | ] 70 | ] 71 | 72 | # Do not include metadata nor timestamps in development logs 73 | config :logger, :console, format: "[$level] $message\n" 74 | 75 | config :logger, truncate: :infinity 76 | 77 | # Set a higher stacktrace during development. Avoid configuring such 78 | # in production as building large stacktraces may be expensive. 79 | config :phoenix, :stacktrace_depth, 20 80 | 81 | # Initialize plugs at runtime for faster development compilation 82 | config :phoenix, :plug_init_mode, :runtime 83 | 84 | import_config "dev.secret.exs" 85 | -------------------------------------------------------------------------------- /config/docker.env: -------------------------------------------------------------------------------- 1 | HOSTNAME=localhost 2 | SECRET_KEY_BASE="u1QXlca4XEZKb1o3HL/aUlznI1qstCNAQ6yme/lFbFIs0Iqiq/annZ+Ty8JyUCDc" 3 | DATABASE_HOST=db 4 | DATABASE_USER=postgres 5 | DATABASE_PASS=postgres 6 | DATABASE_NAME=lucidboard 7 | PORT=4000 8 | LANG=en_US.UTF-8 9 | ERLANG_COOKIE=lucidboard 10 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :lucidboard, LucidboardWeb.Endpoint, 13 | http: [:inet6, port: System.get_env("PORT") || 8080], 14 | url: [host: "localhost", port: 8080], 15 | cache_static_manifest: "priv/static/cache_manifest.json", 16 | server: true 17 | 18 | config :lucidboard, Lucidboard.Repo, 19 | adapter: Ecto.Adapters.Postgres, 20 | username: System.get_env("PG_USER") || "postgres", 21 | password: System.get_env("PG_PASS") || "verysecure123", 22 | database: System.get_env("PG_DB") || "lucidboard_prod", 23 | hostname: System.get_env("PG_HOST") || "db", 24 | port: System.get_env("PG_PORT") || 5432, 25 | pool_size: 10 26 | 27 | # Do not print debug messages in production 28 | config :logger, level: :info 29 | 30 | # ## SSL Support 31 | # 32 | # To get SSL working, you will need to add the `https` key 33 | # to the previous section and set your `:url` port to 443: 34 | # 35 | # config :lucidboard, LucidboardWeb.Endpoint, 36 | # ... 37 | # url: [host: "example.com", port: 443], 38 | # https: [ 39 | # :inet6, 40 | # port: 443, 41 | # cipher_suite: :strong, 42 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 43 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 44 | # ] 45 | # 46 | # The `cipher_suite` is set to `:strong` to support only the 47 | # latest and more secure SSL ciphers. This means old browsers 48 | # and clients may not be supported. You can set it to 49 | # `:compatible` for wider support. 50 | # 51 | # `:keyfile` and `:certfile` expect an absolute path to the key 52 | # and cert in disk or a relative path inside priv, for example 53 | # "priv/ssl/server.key". For all supported SSL configuration 54 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 55 | # 56 | # We also recommend setting `force_ssl` in your endpoint, ensuring 57 | # no data is ever sent via http, always redirecting to https: 58 | # 59 | # config :lucidboard, LucidboardWeb.Endpoint, 60 | # force_ssl: [hsts: true] 61 | # 62 | # Check `Plug.SSL` for all available options in `force_ssl`. 63 | 64 | # ## Using releases (distillery) 65 | # 66 | # If you are doing OTP releases, you need to instruct Phoenix 67 | # to start the server for all endpoints: 68 | # 69 | # config :phoenix, :serve_endpoints, true 70 | # 71 | # Alternatively, you can configure exactly which server to 72 | # start per endpoint: 73 | # 74 | # config :lucidboard, LucidboardWeb.Endpoint, server: true 75 | # 76 | # Note you can't rely on `System.get_env/1` when using releases. 77 | # See the releases documentation accordingly. 78 | 79 | # Finally import the config/prod.secret.exs which should be versioned 80 | # separately. 81 | # import_config "prod.secret.exs" 82 | -------------------------------------------------------------------------------- /config/releases.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :lucidboard, LucidboardWeb.Endpoint, 4 | url: [host: System.get_env("URL_HOST", "localhost")] 5 | 6 | config :lucidboard, Lucidboard.Repo, 7 | database: System.get_env("PG_DB", "lucidboard_prod"), 8 | hostname: System.get_env("PG_HOST", "db"), 9 | port: System.get_env("PG_PORT", "5432"), 10 | password: System.get_env("PG_PASS", "verysecure123") 11 | 12 | config :lucidboard, 13 | :auth_provider, 14 | String.to_atom(System.get_env("AUTH_PROVIDER", "dumb")) 15 | 16 | config :ueberauth, Ueberauth.Strategy.Github.OAuth, 17 | client_id: System.get_env("GITHUB_CLIENT_ID"), 18 | client_secret: System.get_env("GITHUB_CLIENT_SECRET") 19 | 20 | config :ueberauth, Ueberauth.Strategy.PingFed.OAuth, 21 | site: System.get_env("PINGFED_SITE"), 22 | redirect_uri: System.get_env("PINGFED_REDIRECT_URI"), 23 | client_id: System.get_env("PINGFED_CLIENT_ID"), 24 | client_secret: System.get_env("PINGFED_CLIENT_SECRET") 25 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :lucidboard, LucidboardWeb.Endpoint, 6 | http: [port: 8801], 7 | server: false 8 | 9 | config :lucidboard, Lucidboard.Repo, pool: Ecto.Adapters.SQL.Sandbox 10 | 11 | # Print only warnings and errors during test 12 | config :logger, level: :warn 13 | 14 | config :lucidboard, Lucidboard.Repo, 15 | adapter: Ecto.Adapters.Postgres, 16 | username: System.get_env("PG_USER") || "postgres", 17 | password: System.get_env("PG_PASS") || "verysecure123", 18 | database: System.get_env("PG_DB") || "lucidboard_test", 19 | hostname: System.get_env("PG_HOST") || "localhost", 20 | pool_size: 10 21 | 22 | config :ueberauth, Ueberauth.Strategy.Github.OAuth, 23 | client_id: System.get_env("GITHUB_CLIENT_ID"), 24 | client_secret: System.get_env("GITHUB_CLIENT_SECRET") 25 | -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | Lucidboard has a modular authentication scheme. By default, Github.com logins 4 | are enabled and you must add secrets, found in your Github.com account, to 5 | the configuration. 6 | 7 | ## Github.com Strategy 8 | 9 | In order to set up Github.com authentication in Lucidboard, you'll need to 10 | create a new set of OAuth credentials and add them to your configuration. 11 | For your dev environment, make sure you've done an initial start-up since 12 | this will generate the initial `config/dev.secret.exs` file with the database 13 | connection info. 14 | 15 | * Navigate to Github.com. Click your avatar in the top right corner and choose 16 | "Settings". 17 | * In the left sidebar, navigate to "Developer settings". 18 | * In the left sidebar, navigate to "OAuth Apps". 19 | * In the top-right, click the "New OAuth App" button. 20 | * Fill out the form 21 | * Choose an application name. A good one might be `lucidboard-dev`. 22 | * Homepage url should be the root of your instance -- perhaps 23 | `http://localhost:8800/`. 24 | * Application description can be filled if you want. 25 | * Authorization callback URL should start with your homepage url and end with 26 | `/auth/github/callback`, so you might use 27 | `http://localhost:8800/auth/github/callback`. 28 | 29 | When you press the "Register application" button, you'll find the generated 30 | "Client ID" and "Client Secret". Open your `config/dev.secret.exs` and add 31 | the following configuration, replacing the ellipses with your secrets: 32 | 33 | config :ueberauth, Ueberauth.Strategy.Github.OAuth, 34 | client_id: "...", 35 | client_secret: "..." 36 | 37 | Now, if you start up your dev environment, Github.com logins should work! -------------------------------------------------------------------------------- /lib/lucidboard.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard do 2 | @moduledoc "A collaborative kanban tool!" 3 | alias Phoenix.PubSub 4 | 5 | @pubsub Lucidboard.PubSub 6 | @timezone Application.get_env(:lucidboard, :timezone) 7 | @datetime_format_short "%-m/%-d %l:%M %p" 8 | @datetime_format_long "%Y/%-m/%-d %l:%M %p" 9 | 10 | @doc false 11 | def subscribe(topic) do 12 | PubSub.subscribe(@pubsub, topic) 13 | end 14 | 15 | @doc false 16 | def unsubscribe(topic) do 17 | PubSub.unsubscribe(@pubsub, topic) 18 | end 19 | 20 | @doc false 21 | def broadcast(topic, message) do 22 | PubSub.broadcast(@pubsub, topic, message) 23 | end 24 | 25 | @doc """ 26 | Convert a UTC DateTime to one for our time zone. 27 | 28 | Also, microsecond information is removed because Ecto doesn't want it. 29 | """ 30 | @spec utc_to_local(DateTime.t()) :: DateTime.t() 31 | def utc_to_local(utc_datetime) do 32 | utc_datetime 33 | |> Timex.to_datetime(@timezone) 34 | |> DateTime.truncate(:second) 35 | end 36 | 37 | @spec utc_to_formatted(DateTime.t()) :: String.t() 38 | def utc_to_formatted(utc_datetime, mode \\ :short) do 39 | utc_datetime 40 | |> utc_to_local() 41 | |> format(mode) 42 | end 43 | 44 | def format(datetime, mode \\ :short) 45 | 46 | def format(datetime, :short), 47 | do: Timex.format!(datetime, @datetime_format_short, :strftime) 48 | 49 | def format(datetime, :long), 50 | do: Timex.format!(datetime, @datetime_format_long, :strftime) 51 | 52 | def auth_provider do 53 | Application.get_env(:lucidboard, :auth_provider) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/lucidboard/account.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Account do 2 | @moduledoc "Context for user things" 3 | import Ecto.Query 4 | alias Ecto.Changeset 5 | alias Lucidboard.Account.{Github, PingFed} 6 | alias Lucidboard.{Board, BoardRole, BoardSettings, Card, Repo, User} 7 | alias Ueberauth.Auth 8 | require Logger 9 | 10 | @providers %{ 11 | github: Github, 12 | pingfed: PingFed 13 | } 14 | 15 | def get!(user_id), do: Repo.get!(User, user_id) 16 | 17 | def get(user_id), do: Repo.get(User, user_id) 18 | 19 | def by_username(username) do 20 | Repo.one(from(u in User, where: u.name == ^username)) 21 | end 22 | 23 | def create(fields) do 24 | fields |> User.new() |> insert() 25 | end 26 | 27 | def insert(%User{} = user) do 28 | Repo.insert(user) 29 | end 30 | 31 | def display_name(%User{name: name, full_name: full_name}) do 32 | "#{name} (#{full_name})" 33 | end 34 | 35 | @spec has_role?(User.t(), Board.t(), atom) :: boolean 36 | def has_role?(user, board, role \\ :owner) 37 | 38 | def has_role?(_user, %Board{settings: %BoardSettings{access: "open"}}, role) 39 | when role in [:observer, :contributor] do 40 | true 41 | end 42 | 43 | def has_role?( 44 | _user, 45 | %Board{settings: %BoardSettings{access: "public"}}, 46 | :observer 47 | ) do 48 | true 49 | end 50 | 51 | def has_role?(%User{admin: true}, _board, _role) do 52 | true 53 | end 54 | 55 | def has_role?(%User{id: user_id}, %Board{board_roles: roles}, role) do 56 | Enum.any?(roles, fn 57 | %{user_id: ^user_id, role: :owner} -> 58 | true 59 | 60 | %{user_id: ^user_id, role: :contributor} -> 61 | role in [:contributor, :observer] 62 | 63 | %{user_id: ^user_id, role: :observer} -> 64 | role == :observer 65 | 66 | _ -> 67 | false 68 | end) 69 | end 70 | 71 | @spec card_is_editable?(Card.t(), User.t(), Board.t()) :: boolean 72 | def card_is_editable?(card, user, board) 73 | 74 | def card_is_editable?(_, %User{admin: true}, _), do: true 75 | 76 | def card_is_editable?(%Card{user: %User{id: user_id}}, %User{id: user_id}, _), 77 | do: true 78 | 79 | def card_is_editable?(_, %User{id: user_id}, %Board{board_roles: board_roles}) do 80 | Enum.any?(board_roles, fn %{user: %{id: uid}, role: :owner} -> 81 | uid == user_id 82 | end) 83 | end 84 | 85 | @spec suggest_users(String.t(), User.t()) :: [User.t()] 86 | def suggest_users(query, %User{id: user_id}) do 87 | q = "%#{query}%" 88 | 89 | Repo.all( 90 | from(u in User, 91 | where: 92 | (ilike(u.name, ^q) or ilike(u.full_name, ^q)) and u.id != ^user_id, 93 | order_by: [asc: u.name] 94 | 95 | ) 96 | ) 97 | end 98 | 99 | @spec grant(integer, BoardRole.t()) :: :ok | {:error, Changeset.t()} 100 | def grant(board_id, board_role) do 101 | with %Board{} = board <- 102 | Board |> Repo.get(board_id) |> Repo.preload(:board_roles), 103 | {:ok, _} <- 104 | board 105 | |> Board.changeset() 106 | |> Changeset.put_assoc(:board_roles, [board_role | board.board_roles]) 107 | |> Repo.update() do 108 | :ok 109 | else 110 | {:error, error} -> {:error, error} 111 | end 112 | end 113 | 114 | @spec revoke(integer, integer) :: :ok 115 | def revoke(user_id, board_id) do 116 | Repo.delete_all( 117 | from(r in BoardRole, 118 | where: r.user_id == ^user_id and r.board_id == ^board_id 119 | ) 120 | ) 121 | 122 | :ok 123 | end 124 | 125 | @doc """ 126 | Given the `%Ueberauth.Auth{}` result, get a loaded user from the db. 127 | 128 | If one does not exist, it will be created. 129 | """ 130 | @spec auth_to_user(Auth.t()) :: {:ok, User.t()} | {:error, String.t()} 131 | def auth_to_user(auth) do 132 | with {:ok, user} <- apply(@providers[auth.provider], :to_user, [auth]) do 133 | case Repo.one(from(u in User, where: u.name == ^user.name)) do 134 | nil -> Repo.insert(user) 135 | db_user -> {:ok, db_user} 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/lucidboard/account/github.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Account.Github do 2 | @moduledoc "Logic specific to using Github as an auth provider" 3 | alias Lucidboard.User 4 | alias Ueberauth.Auth 5 | 6 | def to_user(%Auth{} = auth) do 7 | user = 8 | User.new( 9 | name: nickname_from_auth(auth), 10 | full_name: name_from_auth(auth), 11 | avatar_url: avatar_from_auth(auth) 12 | ) 13 | 14 | {:ok, user} 15 | end 16 | 17 | defp nickname_from_auth(%{info: %{nickname: nickname}}), do: nickname 18 | 19 | defp name_from_auth(auth) do 20 | with nil <- Map.get(auth.info, :name) do 21 | name = 22 | [auth.info.first_name, auth.info.last_name] 23 | |> Enum.filter(&(&1 != nil and &1 != "")) 24 | 25 | if Enum.empty?(name) do 26 | auth.info.nickname 27 | else 28 | Enum.join(name, " ") 29 | end 30 | end 31 | end 32 | 33 | defp avatar_from_auth(%{info: %{urls: %{avatar_url: image}}}), do: image 34 | defp avatar_from_auth(_), do: nil 35 | end 36 | -------------------------------------------------------------------------------- /lib/lucidboard/account/ping_fed.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Account.PingFed do 2 | @moduledoc "Logic specific to using Ping Federate as an auth provider" 3 | alias Lucidboard.User 4 | alias Ueberauth.Auth 5 | 6 | def to_user(%Auth{} = auth) do 7 | user = 8 | User.new( 9 | name: nickname_from_auth(auth), 10 | full_name: name_from_auth(auth), 11 | avatar_url: avatar_from_auth(auth) 12 | ) 13 | 14 | {:ok, user} 15 | end 16 | 17 | defp nickname_from_auth(%{info: %{nickname: nickname}}), do: nickname 18 | 19 | defp name_from_auth(auth) do 20 | with nil <- Map.get(auth.info, :name) do 21 | name = 22 | [auth.info.first_name, auth.info.last_name] 23 | |> Enum.filter(&(&1 != nil and &1 != "")) 24 | 25 | if Enum.empty?(name), 26 | do: auth.info.nickname, 27 | else: Enum.join(name, " ") 28 | end 29 | end 30 | 31 | defp avatar_from_auth(%{info: %{urls: %{avatar_url: image}}}), do: image 32 | defp avatar_from_auth(_), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /lib/lucidboard/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Application do 2 | @moduledoc false 3 | use Application 4 | alias Lucidboard.LiveBoard 5 | alias LucidboardWeb.Endpoint 6 | 7 | def start(_type, _args) do 8 | import Supervisor.Spec 9 | 10 | IO.puts(banner()) 11 | 12 | children = [ 13 | supervisor(Endpoint, []), 14 | supervisor(Lucidboard.Repo, []), 15 | LiveBoard.registry_child_spec(), 16 | LiveBoard.dynamic_supervisor_child_spec(), 17 | Lucidboard.Presence 18 | ] 19 | 20 | opts = [strategy: :one_for_one, name: Lucidboard.Supervisor] 21 | Supervisor.start_link(children, opts) 22 | end 23 | 24 | def config_change(changed, _new, removed) do 25 | Endpoint.config_change(changed, removed) 26 | :ok 27 | end 28 | 29 | defp banner, 30 | do: ~S""" 31 | __ _ _ _ _ 32 | / / _ _ ___(_) __| | |__ ___ __ _ _ __ __| | 33 | / / | | | |/ __| |/ _` | '_ \ / _ \ / _` | '__/ _` | 34 | / /__| |_| | (__| | (_| | |_) | (_) | (_| | | | (_| | 35 | \____/\__,_|\___|_|\__,_|_.__/ \___/ \__,_|_| \__,_| 36 | """ 37 | end 38 | -------------------------------------------------------------------------------- /lib/lucidboard/ecto_enums.ex: -------------------------------------------------------------------------------- 1 | import EctoEnum 2 | defenum(BoardRoleEnum, owner: 0, contributor: 1, observer: 2) 3 | -------------------------------------------------------------------------------- /lib/lucidboard/live_board.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.LiveBoard do 2 | @moduledoc """ 3 | Facade functions for a system that manages Lucidboard states. 4 | 5 | To use, install the child specs returned from `®istry_child_spec/0` and 6 | `&dynamic_supervisor_child_spec/0` into your supervision tree. Then, use 7 | the start and stop functions to manage LiveBoard processes and the call 8 | function to interact with a running one. 9 | """ 10 | alias Lucidboard.LiveBoard.{Agent, BoardRegistry, BoardSupervisor, Scribe} 11 | 12 | @spec registry_child_spec :: Supervisor.child_spec() 13 | def registry_child_spec do 14 | {Registry, keys: :unique, name: BoardRegistry} 15 | end 16 | 17 | @spec dynamic_supervisor_child_spec :: Supervisor.child_spec() 18 | def dynamic_supervisor_child_spec do 19 | {DynamicSupervisor, name: BoardSupervisor, strategy: :one_for_one} 20 | end 21 | 22 | @doc """ 23 | Starts a LiveBoard 24 | 25 | * Pass a board id to load from the db 26 | * Pass a `%Board{}` either 27 | * With an `:id` 28 | * Without an `:id` - board will be inserted first. 29 | """ 30 | @spec start(integer, keyword) :: 31 | DynamicSupervisor.on_start_child() 32 | | {:error, :no_board} 33 | | {:error, Ecto.Changeset.t()} 34 | def start(id, opts \\ []) when is_integer(id) do 35 | supervisor = Keyword.get(opts, :supervisor, BoardSupervisor) 36 | registry = Keyword.get(opts, :registry, BoardRegistry) 37 | 38 | scribe_name = {:via, Registry, {registry, {:scribe, id}}} 39 | scribe_child_spec = {Scribe, scribe_name} 40 | DynamicSupervisor.start_child(supervisor, scribe_child_spec) 41 | 42 | name = {:via, Registry, {registry, {:agent, id}}} 43 | child_spec = {Agent, {id, name}} 44 | DynamicSupervisor.start_child(supervisor, child_spec) 45 | end 46 | 47 | @doc "Stops a LiveBoard process by its board id" 48 | @spec stop(integer) :: :ok | {:error, :not_found} 49 | def stop(id, opts \\ []) do 50 | supervisor = Keyword.get(opts, :supervisor, BoardSupervisor) 51 | registry = Keyword.get(opts, :registry, BoardRegistry) 52 | 53 | case Registry.lookup(registry, {:agent, id}) do 54 | [{agent_pid, nil}] -> 55 | DynamicSupervisor.terminate_child(supervisor, agent_pid) 56 | 57 | _ -> 58 | nil 59 | end 60 | 61 | case Registry.lookup(registry, {:scribe, id}) do 62 | [{scribe_pid, nil}] -> 63 | DynamicSupervisor.terminate_child(supervisor, scribe_pid) 64 | 65 | _ -> 66 | nil 67 | end 68 | end 69 | 70 | @doc """ 71 | Uses GenServer.call to act upon a LiveBoard Agent, starting it if it 72 | doesn't already exist 73 | """ 74 | @spec call(integer, any, keyword) :: {:ok, any} | {:error, String.t()} 75 | def call(board_id, msg, opts \\ []) do 76 | existing_pid = 77 | board_id 78 | |> via_agent(Keyword.get(opts, :registry, BoardRegistry)) 79 | |> GenServer.whereis() 80 | 81 | with {:ok, pid} <- (existing_pid && {:ok, existing_pid}) || start(board_id), 82 | {:ok, ret} <- GenServer.call(pid, msg) do 83 | {:ok, ret} 84 | else 85 | unwrapped -> {:ok, unwrapped} 86 | end 87 | end 88 | 89 | @doc "Returns the via tuple for accessing the Agent process." 90 | def via_agent(board_id, registry \\ BoardRegistry), 91 | do: {:via, Registry, {registry, {:agent, board_id}}} 92 | 93 | @doc "Returns the via tuple for accessing the Scribe process." 94 | def via_scribe(board_id, registry \\ BoardRegistry), 95 | do: {:via, Registry, {registry, {:scribe, board_id}}} 96 | end 97 | -------------------------------------------------------------------------------- /lib/lucidboard/live_board/agent.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.LiveBoard.Agent do 2 | @moduledoc """ 3 | GenServer for a live board 4 | """ 5 | use GenServer 6 | alias Lucidboard.{Board, Event, ShortBoard, TimeMachine, Twiddler, User} 7 | alias Lucidboard.LiveBoard.Scribe 8 | require Logger 9 | 10 | defmodule State do 11 | @moduledoc """ 12 | The state of a live board 13 | 14 | * `:board` - The current state as `%Board{}` 15 | * `:events` - The most recent page of events that have occurred 16 | """ 17 | defstruct board: nil, changeset: nil, events: [] 18 | 19 | @type t :: %__MODULE__{ 20 | board: Board.t(), 21 | events: [Event.t()] 22 | } 23 | end 24 | 25 | def start_link({board_id, name}) do 26 | GenServer.start_link(__MODULE__, board_id, name: name) 27 | end 28 | 29 | @impl true 30 | def init(board_id) do 31 | Logger.info("Initializing board agent (id #{board_id})") 32 | 33 | case {Twiddler.by_id(board_id), TimeMachine.events(board_id)} do 34 | {%Board{} = board, events} -> {:ok, %State{board: board, events: events}} 35 | _ -> {:stop, :not_found} 36 | end 37 | end 38 | 39 | @impl true 40 | def handle_call({:action, action}, from, state) do 41 | handle_call({:action, action, []}, from, state) 42 | end 43 | 44 | @impl true 45 | def handle_call({:action, action, opts}, _from, state) when is_list(opts) do 46 | case Twiddler.act(state.board, action, opts) do 47 | {:ok, new_board, tx_fn, meta, event} -> 48 | user = Keyword.get(opts, :user) 49 | {event, events} = add_event(state.events, event, new_board, user) 50 | new_state = %{state | board: new_board, events: events} 51 | 52 | if event && event.desc =~ ~r/board access/ do 53 | # If the last event changed the board access setting, we'll wait a 54 | # moment to be sure the Scribe has written to the database, then 55 | # broadcast to have all Dashboard views do a full refresh. 56 | Process.send_after(self(), :reload_all_dashboards, 200) 57 | end 58 | 59 | Lucidboard.broadcast( 60 | "board:#{new_board.id}", 61 | {:update, new_board, event} 62 | ) 63 | 64 | Lucidboard.broadcast( 65 | "short_board:#{new_board.id}", 66 | {:short_board, ShortBoard.from_board(new_board, events)} 67 | ) 68 | 69 | Scribe.write(new_board.id, [ 70 | tx_fn, 71 | if(event, do: fn -> TimeMachine.commit(event) end) 72 | ]) 73 | 74 | ret = 75 | if Keyword.get(opts, :return_board, false), 76 | do: %{meta | board: new_board}, 77 | else: meta 78 | 79 | {:reply, {:ok, ret}, new_state} 80 | 81 | {:error, message} -> 82 | {:reply, {:error, message}, state} 83 | end 84 | end 85 | 86 | def handle_call(:state, _from, state) do 87 | ret = %{board: state.board, events: state.events} 88 | {:reply, ret, state} 89 | end 90 | 91 | def handle_call({:likes_left_for, %User{id: _user_id}}, _from, state) do 92 | {:reply, :tbi, state} 93 | end 94 | 95 | def handle_call(:events, _from, state) do 96 | {:reply, state.events, state} 97 | end 98 | 99 | @impl true 100 | def handle_info(:reload_all_dashboards, state) do 101 | Lucidboard.broadcast("dashboards", :full_reload) 102 | {:noreply, state} 103 | end 104 | 105 | defp add_event(events, nil, _board, _user) do 106 | {nil, events} 107 | end 108 | 109 | defp add_event(events, event, board, user) do 110 | event = %{event | board: board, user: user} 111 | events = Enum.slice([event | events], 0, TimeMachine.page_size()) 112 | 113 | {event, events} 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/lucidboard/live_board/scribe.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.LiveBoard.Scribe do 2 | @moduledoc """ 3 | LiveBoard-specific process, responsible for committing data to the database. 4 | 5 | The idea here is to have a separate process for these Repo calls so the 6 | user is not blocked by the operation. 7 | """ 8 | use GenServer 9 | alias Lucidboard.{LiveBoard, Repo} 10 | require Logger 11 | 12 | @type tx_fn :: fun | [fun] 13 | 14 | @doc "Cast a write operation to the scribe process" 15 | @spec write(integer, tx_fn) :: :ok 16 | def write(board_id, tx_fn) do 17 | board_id 18 | |> LiveBoard.via_scribe() 19 | |> GenServer.cast(tx_fn) 20 | end 21 | 22 | def start_link(name) do 23 | GenServer.start_link(__MODULE__, nil, name: name) 24 | end 25 | 26 | @impl true 27 | def init(nil), do: {:ok, nil} 28 | 29 | @impl true 30 | def handle_cast(tx_fn, state) do 31 | case execute_tx_fn(tx_fn) do 32 | {:ok, _struct} -> nil 33 | nil -> nil 34 | bad -> Logger.error("Repo.update on changeset failed: #{inspect(bad)}") 35 | end 36 | 37 | {:noreply, state} 38 | end 39 | 40 | def execute_tx_fn(functions, tx \\ true) 41 | 42 | def execute_tx_fn(functions, tx) when is_list(functions) do 43 | go = fn -> 44 | Enum.each(functions, fn fun -> execute_tx_fn(fun, false) end) 45 | end 46 | 47 | if tx, do: Repo.transaction(go), else: go.() 48 | end 49 | 50 | def execute_tx_fn(nil, _), do: nil 51 | 52 | def execute_tx_fn(fun, tx) do 53 | if tx, do: Repo.transaction(fun), else: fun.() 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/lucidboard/presence.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Presence do 2 | @moduledoc "Lucidboard presence!" 3 | use Phoenix.Presence, 4 | otp_app: :lucidboard, 5 | pubsub_server: Lucidboard.PubSub 6 | 7 | @spec get_for_session( 8 | String.t(), 9 | integer | String.t(), 10 | String.t(), 11 | String.t() 12 | ) :: any() 13 | def get_for_session(topic, user_id, lv_ref, key) do 14 | with user_id <- to_string(user_id), 15 | list <- list(topic), 16 | {:ok, %{metas: metas}} <- Map.fetch(list, user_id), 17 | meta when not is_nil(meta) <- 18 | Enum.find(metas, fn m -> m.lv_ref == lv_ref end) do 19 | Map.get(meta, key) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/lucidboard/release.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Release do 2 | @moduledoc "Do tasks related to deployment" 3 | alias Ecto.Migrator 4 | 5 | @app :lucidboard 6 | 7 | def migrate do 8 | load_app() 9 | 10 | for repo <- repos() do 11 | {:ok, _, _} = Migrator.with_repo(repo, &Migrator.run(&1, :up, all: true)) 12 | end 13 | end 14 | 15 | def rollback(repo, version) do 16 | load_app() 17 | 18 | {:ok, _, _} = 19 | Migrator.with_repo(repo, &Migrator.run(&1, :down, to: version)) 20 | end 21 | 22 | defp repos do 23 | Application.fetch_env!(@app, :ecto_repos) 24 | end 25 | 26 | defp load_app do 27 | Application.ensure_all_started(@app) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/lucidboard/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Repo do 2 | use Ecto.Repo, 3 | otp_app: :lucidboard, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | use Scrivener, page_size: 10 7 | 8 | @doc """ 9 | Dynamically loads the repository url from the 10 | DATABASE_URL environment variable. 11 | """ 12 | def init(_, opts) do 13 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/board.ex: -------------------------------------------------------------------------------- 1 | defimpl Inspect, for: Lucidboard.Board do 2 | import Inspect.Algebra 3 | 4 | def inspect(_dict, _opts) do 5 | concat(["#Board<>"]) 6 | end 7 | end 8 | 9 | defmodule Lucidboard.Board do 10 | @moduledoc "Schema for a board record" 11 | use Ecto.Schema 12 | import Ecto.Changeset 13 | alias Lucidboard.{BoardRole, BoardSettings, Column, Event, User} 14 | 15 | @derive {Jason.Encoder, only: ~w(id title settings columns)a} 16 | 17 | schema "boards" do 18 | field(:title, :string) 19 | embeds_one(:settings, BoardSettings, on_replace: :update) 20 | has_many(:columns, Column) 21 | has_many(:events, Event) 22 | belongs_to(:user, User) 23 | has_many(:board_roles, BoardRole) 24 | 25 | field(:inserted_at, :utc_datetime) 26 | field(:updated_at, :utc_datetime) 27 | end 28 | 29 | @spec new(keyword) :: Board.t() 30 | def new(fields \\ []) do 31 | now = DateTime.truncate(DateTime.utc_now(), :second) 32 | 33 | defaults = [ 34 | settings: BoardSettings.new(), 35 | inserted_at: now, 36 | updated_at: now 37 | ] 38 | 39 | struct(__MODULE__, Keyword.merge(defaults, fields)) 40 | end 41 | 42 | @doc false 43 | def changeset(board, attrs \\ %{}) do 44 | board 45 | |> cast(attrs, [:title]) 46 | |> validate_required([:title]) 47 | |> cast_assoc(:columns) 48 | |> cast_assoc(:board_roles) 49 | |> cast_embed(:settings) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/board_role.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.BoardRole do 2 | @moduledoc "Schema for role a user has on a board" 3 | use Ecto.Schema 4 | alias Ecto.UUID 5 | alias Lucidboard.{Board, User} 6 | 7 | @primary_key {:id, :binary_id, autogenerate: false} 8 | # @derive {Jason.Encoder, only: ~w(id)a} 9 | 10 | schema "board_roles" do 11 | belongs_to(:board, Board) 12 | belongs_to(:user, User) 13 | field(:role, BoardRoleEnum) 14 | end 15 | 16 | @spec new(keyword) :: Like.t() 17 | def new(fields \\ []) do 18 | defaults = [id: UUID.generate()] 19 | struct(__MODULE__, Keyword.merge(defaults, fields)) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/board_settings.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.BoardSettings do 2 | @moduledoc "Schema for a board's settings" 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | 6 | @primary_key false 7 | # @derive {Jason.Encoder, only: ~w(likes_per_user likes_per_user_per_card)a} 8 | 9 | embedded_schema do 10 | field(:likes_per_user, :integer, default: 3) 11 | field(:likes_per_user_per_card, :integer, default: 3) 12 | field(:access, :string, default: "open") 13 | field(:anonymous, :boolean, default: true) 14 | end 15 | 16 | @spec new(keyword) :: BoardSettings.t() 17 | def new(fields \\ [], type \\ :struct) do 18 | if type == :struct, 19 | do: struct(__MODULE__, fields), 20 | else: Enum.into(fields, %{}) 21 | end 22 | 23 | @doc false 24 | def changeset(settings, attrs \\ %{}) do 25 | per_user = 26 | case attrs["likes_per_user"] || settings.likes_per_user do 27 | str when is_binary(str) -> String.to_integer(str) 28 | int when is_integer(int) -> int 29 | end 30 | 31 | settings 32 | |> cast(attrs, [:likes_per_user, :likes_per_user_per_card, :access]) 33 | |> validate_number(:likes_per_user_per_card, less_than_or_equal_to: per_user) 34 | |> validate_access(:access) 35 | end 36 | 37 | def validate_access(changeset, field, options \\ []) do 38 | validate_change(changeset, field, fn _, access -> 39 | case access in ["open", "private"] do 40 | true -> [] 41 | false -> [{field, options[:message] || "Unexpected Access Value"}] 42 | end 43 | end) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/card.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Card do 2 | @moduledoc "Schema for a board record" 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | alias Ecto.UUID 6 | alias Lucidboard.{CardSettings, Like, Pile, User} 7 | 8 | @primary_key {:id, :binary_id, autogenerate: false} 9 | @derive {Jason.Encoder, only: ~w(id pos body locked settings likes)a} 10 | 11 | schema "cards" do 12 | field(:pos, :integer) 13 | field(:body, :string) 14 | # field(:locked, :boolean) 15 | # field(:locked_by, User) 16 | embeds_one(:settings, CardSettings, on_replace: :update) 17 | belongs_to(:pile, Pile, type: :binary_id) 18 | belongs_to(:user, User) 19 | # many_to_many(:users_liked, User, join_through: Like) 20 | # No idea why this `on_delete` is needed when it seems to be set up 21 | # properly in the db migration 22 | has_many(:likes, Like, on_delete: :delete_all) 23 | end 24 | 25 | @spec new(keyword) :: Card.t() 26 | def new(fields \\ []) do 27 | defaults = [ 28 | id: UUID.generate(), 29 | pos: 0, 30 | body: "", 31 | locked: false, 32 | settings: CardSettings.new(), 33 | likes: [] 34 | ] 35 | 36 | struct(__MODULE__, Keyword.merge(defaults, fields)) 37 | end 38 | 39 | def changeset(card, attrs \\ %{}) do 40 | settings = 41 | if attrs["color"] do 42 | %{color: attrs["color"]} 43 | else 44 | attrs[:settings] 45 | end 46 | 47 | if settings do 48 | card 49 | |> cast(attrs, [:body, :pile_id, :pos]) 50 | |> put_change(:settings, settings) 51 | else 52 | card 53 | |> cast(attrs, [:body, :pile_id, :pos]) 54 | end 55 | end 56 | 57 | @doc "Get the number of likes on a card" 58 | def like_count(%__MODULE__{likes: likes}) do 59 | length(likes) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/card_settings.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.CardSettings do 2 | @moduledoc "Schema for a card's settings" 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | 6 | @primary_key false 7 | @derive {Jason.Encoder, only: ~w(color)a} 8 | 9 | embedded_schema do 10 | field(:color, :string) 11 | end 12 | 13 | @spec new(keyword) :: CardSettings.t() 14 | def new(fields \\ []) do 15 | defaults = [color: "none"] 16 | struct(__MODULE__, Keyword.merge(defaults, fields)) 17 | end 18 | 19 | @doc false 20 | def changeset(settings, attrs) do 21 | settings 22 | |> cast(attrs, [:color]) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/column.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Column do 2 | @moduledoc "Schema for a board record" 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | alias Ecto.UUID 6 | alias Lucidboard.{Board, Pile} 7 | 8 | @primary_key {:id, :binary_id, autogenerate: false} 9 | @derive {Jason.Encoder, only: ~w(id title pos piles)a} 10 | 11 | schema "columns" do 12 | field(:title, :string) 13 | field(:pos, :integer) 14 | has_many(:piles, Pile) 15 | belongs_to(:board, Board) 16 | end 17 | 18 | @spec new(keyword) :: Column.t() 19 | def new(fields \\ [], type \\ :struct) do 20 | defaults = [id: UUID.generate(), pos: 0, piles: []] 21 | data = Keyword.merge(defaults, fields) 22 | 23 | if type == :struct, 24 | do: struct(__MODULE__, data), 25 | else: Enum.into(data, %{}) 26 | end 27 | 28 | @doc false 29 | def changeset(column, attrs) do 30 | column 31 | |> cast(attrs, [:id, :title, :pos, :board_id]) 32 | |> cast_assoc(:piles) 33 | |> validate_required([:title]) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/event.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Event do 2 | @moduledoc """ 3 | Something that has occurred on a Lucidboard 4 | """ 5 | use Ecto.Schema 6 | alias Lucidboard.{Board, User} 7 | 8 | schema "events" do 9 | belongs_to(:board, Board) 10 | belongs_to(:user, User) 11 | field(:desc) 12 | 13 | field(:inserted_at, :utc_datetime) 14 | field(:updated_at, :utc_datetime) 15 | end 16 | 17 | @spec new(keyword) :: Board.t() 18 | def new(fields \\ []) do 19 | now = DateTime.truncate(DateTime.utc_now(), :second) 20 | 21 | defaults = [ 22 | inserted_at: now, 23 | updated_at: now 24 | ] 25 | 26 | struct(__MODULE__, Keyword.merge(defaults, fields)) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/like.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Like do 2 | @moduledoc "Schema for a user's like on a card" 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | alias Ecto.UUID 6 | alias Lucidboard.{Card, User} 7 | 8 | @fields [:user_id, :card_id] 9 | @primary_key {:id, :binary_id, autogenerate: false} 10 | @derive {Jason.Encoder, only: ~w(id)a} 11 | 12 | schema "likes" do 13 | belongs_to(:card, Card, type: :binary_id) 14 | belongs_to(:user, User) 15 | # field(:count, :integer) 16 | end 17 | 18 | @spec new(keyword) :: Like.t() 19 | def new(fields \\ []) do 20 | defaults = [id: UUID.generate()] 21 | struct(__MODULE__, Keyword.merge(defaults, fields)) 22 | end 23 | 24 | def changeset(like, attrs) do 25 | like 26 | |> cast(attrs, @fields) 27 | |> validate_required(@fields) 28 | |> foreign_key_constraint(:card_id) 29 | |> foreign_key_constraint(:user_id) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/pile.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Pile do 2 | @moduledoc "Schema for a board record" 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | alias Ecto.UUID 6 | alias Lucidboard.{Card, Column} 7 | 8 | @primary_key {:id, :binary_id, autogenerate: false} 9 | @derive {Jason.Encoder, only: ~w(id pos cards)a} 10 | 11 | schema "piles" do 12 | field(:pos, :integer) 13 | has_many(:cards, Card, on_delete: :delete_all) 14 | belongs_to(:column, Column, type: :binary_id) 15 | end 16 | 17 | @spec new(keyword) :: Pile.t() 18 | def new(fields \\ []) do 19 | defaults = [id: UUID.generate(), pos: 0] 20 | struct(__MODULE__, Keyword.merge(defaults, fields)) 21 | end 22 | 23 | @doc false 24 | def changeset(pile, attrs) do 25 | pile 26 | |> cast(attrs, []) 27 | |> cast_assoc(:cards) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/user.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.User do 2 | @moduledoc "Schema for a board record" 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | alias Lucidboard.{BoardRole, Card, Like, UserSettings} 6 | 7 | schema "users" do 8 | field(:name) 9 | field(:full_name) 10 | field(:avatar_url) 11 | field(:admin, :boolean) 12 | embeds_one(:settings, UserSettings, on_replace: :delete) 13 | many_to_many(:cards_liked, Card, join_through: Like) 14 | has_many(:board_roles, BoardRole) 15 | 16 | timestamps() 17 | end 18 | 19 | @spec new(keyword) :: User.t() 20 | def new(fields \\ []) do 21 | defaults = [settings: UserSettings.new(), admin: false] 22 | struct(__MODULE__, Keyword.merge(defaults, fields)) 23 | end 24 | 25 | def changeset(card, attrs) do 26 | card 27 | |> cast(attrs, [:name, :full_name]) 28 | |> validate_required([:name]) 29 | |> unique_constraint(:name) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/lucidboard/schema/user_settings.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.UserSettings do 2 | @moduledoc "Schema for a user's settings" 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | 6 | @primary_key false 7 | @default_theme Application.get_env(:lucidboard, :default_theme) 8 | 9 | embedded_schema do 10 | field(:theme, :string, default: @default_theme) 11 | end 12 | 13 | @spec new(keyword) :: UserSettings.t() 14 | def new(fields \\ []) do 15 | struct(__MODULE__, fields) 16 | end 17 | 18 | @doc false 19 | def changeset(settings, attrs) do 20 | settings 21 | |> cast(attrs, [:theme]) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/lucidboard/short_board.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.ShortBoard do 2 | @moduledoc "Struct for a board in a listing (on the dashboard)" 3 | alias Lucidboard.{Board, Event, TimeMachine} 4 | 5 | @type t :: %__MODULE__{ 6 | id: integer, 7 | title: String.t(), 8 | username: String.t(), 9 | updated_at: DateTime.t(), 10 | last_event: Event.t(), 11 | access: String.t(), 12 | anonymous: boolean 13 | } 14 | 15 | defstruct [ 16 | :id, 17 | :title, 18 | :username, 19 | :inserted_at, 20 | :updated_at, 21 | :last_event, 22 | :access, 23 | :anonymous 24 | ] 25 | 26 | def from_board(%Board{} = board, events \\ nil) do 27 | last_event = 28 | case events do 29 | nil -> board.id |> TimeMachine.events(size: 1) |> List.first() 30 | [ev | _] -> ev 31 | end 32 | 33 | %__MODULE__{ 34 | id: board.id, 35 | title: board.title, 36 | username: board.user.name, 37 | inserted_at: board.inserted_at, 38 | updated_at: board.updated_at, 39 | last_event: last_event, 40 | access: board.settings.access, 41 | anonymous: board.settings.anonymous 42 | } 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/lucidboard/time_machine.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.TimeMachine do 2 | @moduledoc "Manages Events" 3 | import Ecto.Query 4 | alias Lucidboard.{Event, Repo} 5 | 6 | @page_size 40 7 | 8 | def page_size, do: @page_size 9 | 10 | def events(board_id, opts \\ []) do 11 | size = Keyword.get(opts, :size, @page_size) 12 | page = Keyword.get(opts, :page, 1) 13 | 14 | Repo.all( 15 | from(e in Event, 16 | where: e.board_id == ^board_id, 17 | order_by: [desc: e.inserted_at], 18 | limit: ^size, 19 | offset: ^((page - 1) * size), 20 | preload: [:user] 21 | ) 22 | ) 23 | end 24 | 25 | def commit(%Event{} = event) do 26 | %{event | board_id: event.board.id, user_id: event.user.id} 27 | |> Repo.insert() 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/lucidboard/twiddler/lensables.ex: -------------------------------------------------------------------------------- 1 | defimpl Lensable, for: Lucidboard.Board do 2 | def getter(s, x), do: Map.get(s, x, {:error, {:lens, :bad_path}}) 3 | def setter({:error, {:lens, :bad_path}} = e), do: e 4 | 5 | def setter(s, x, f) do 6 | if Map.has_key?(s, x), do: Map.put(s, x, f), else: s 7 | end 8 | end 9 | 10 | defimpl Lensable, for: Lucidboard.Column do 11 | def getter(s, x), do: Map.get(s, x, {:error, {:lens, :bad_path}}) 12 | def setter({:error, {:lens, :bad_path}} = e), do: e 13 | 14 | def setter(s, x, f) do 15 | if Map.has_key?(s, x), do: Map.put(s, x, f), else: s 16 | end 17 | end 18 | 19 | defimpl Lensable, for: Lucidboard.Pile do 20 | def getter(s, x), do: Map.get(s, x, {:error, {:lens, :bad_path}}) 21 | def setter({:error, {:lens, :bad_path}} = e), do: e 22 | 23 | def setter(s, x, f) do 24 | if Map.has_key?(s, x), do: Map.put(s, x, f), else: s 25 | end 26 | end 27 | 28 | defimpl Lensable, for: Lucidboard.Card do 29 | def getter(s, x), do: Map.get(s, x, {:error, {:lens, :bad_path}}) 30 | def setter({:error, {:lens, :bad_path}} = e), do: e 31 | 32 | def setter(s, x, f) do 33 | if Map.has_key?(s, x), do: Map.put(s, x, f), else: s 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/lucidboard/twiddler/query_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Twiddler.QueryBuilder do 2 | @moduledoc """ 3 | Helps build our transaction functions by chaining together Repo calls. 4 | 5 | For convenience, the optional `base_fn` arguments are functions with 6 | preexisting database calls which should be ran before the new ones being 7 | added. This function is ultimately intended to be passed to 8 | `&Repo.transaction/l` for execution. 9 | """ 10 | import Ecto.Query 11 | alias Lucidboard.LiveBoard.Scribe 12 | alias Lucidboard.{Card, Repo} 13 | 14 | @doc """ 15 | Return a function that will remove item with id `id` and position `pos`. 16 | """ 17 | def remove_item(q, id, pos) do 18 | queryable = from(i in q, where: i.pos > ^pos) 19 | 20 | fn -> 21 | Repo.update_all(queryable, inc: [pos: -1]) 22 | Repo.delete(Repo.one!(from(i in q, where: i.id == ^id))) 23 | end 24 | end 25 | 26 | def delete_card(card) do 27 | Repo.delete(Repo.one!(from(c in Card, where: c.id == ^card.id))) 28 | end 29 | 30 | @doc """ 31 | Return a function that will move item with id `id` and position `pos` to 32 | `new_pos` in the database. 33 | 34 | Works for Columns, Piles, or Cards. To aid in building the transaction 35 | function, the first argument must be an `Ecto.Queryable.t()`. (Eg. `from(c 36 | in Column, where: c.board_id == ^board.id)`) 37 | """ 38 | @spec move_item(Ecto.Queryable.t(), integer, integer, integer) :: 39 | Scribe.tx_fn() 40 | def move_item(q, id, pos, new_pos) do 41 | {queryable, pos_delta} = 42 | if pos < new_pos do 43 | qq = from(i in q, where: i.pos > ^pos and i.pos <= ^new_pos) 44 | {qq, -1} 45 | else 46 | qq = from(i in q, where: i.pos < ^pos and i.pos >= ^new_pos) 47 | {qq, 1} 48 | end 49 | 50 | fn -> 51 | Repo.update_all(queryable, inc: [pos: pos_delta]) 52 | Repo.update_all(from(i in q, where: i.id == ^id), set: [pos: new_pos]) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/lucidboard_web.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use LucidboardWeb, :controller 9 | use LucidboardWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: LucidboardWeb 23 | import Plug.Conn 24 | import LucidboardWeb.Gettext 25 | import unquote(__MODULE__), only: [signed_in?: 1] 26 | alias LucidboardWeb.Router.Helpers, as: Routes 27 | 28 | action_fallback(LucidboardWeb.FallbackController) 29 | end 30 | end 31 | 32 | def view do 33 | quote do 34 | use Phoenix.View, 35 | root: "lib/lucidboard_web/templates", 36 | pattern: "**/*", 37 | namespace: LucidboardWeb 38 | 39 | import unquote(__MODULE__), only: [signed_in?: 1] 40 | 41 | # Import convenience functions from controllers 42 | import Phoenix.Controller, 43 | only: [get_flash: 1, get_flash: 2, view_module: 1] 44 | 45 | # Use all HTML functionality (forms, tags, etc) 46 | use Phoenix.HTML 47 | 48 | import LucidboardWeb.ErrorHelpers 49 | import LucidboardWeb.Gettext 50 | import LucidboardWeb.ViewHelper 51 | import Lucidboard, only: [utc_to_formatted: 2] 52 | 53 | import Lucidboard.Account, 54 | only: [ 55 | display_name: 1, 56 | has_role?: 2, 57 | has_role?: 3, 58 | card_is_editable?: 3 59 | ] 60 | 61 | import Phoenix.LiveView, only: [live_render: 2, live_render: 3] 62 | alias Lucidboard.Twiddler.Op 63 | alias LucidboardWeb.BoardLive 64 | alias LucidboardWeb.Router.Helpers, as: Routes 65 | end 66 | end 67 | 68 | def router do 69 | quote do 70 | use Phoenix.Router 71 | import Plug.Conn 72 | import Phoenix.Controller 73 | import Phoenix.LiveView.Router 74 | end 75 | end 76 | 77 | def channel do 78 | quote do 79 | use Phoenix.Channel 80 | import LucidboardWeb.Gettext 81 | end 82 | end 83 | 84 | @doc """ 85 | When used, dispatch to the appropriate controller/view/etc. 86 | """ 87 | defmacro __using__(which) when is_atom(which) do 88 | apply(__MODULE__, which, []) 89 | end 90 | 91 | @doc "Returns true if user is signed in" 92 | def signed_in?(conn) do 93 | not is_nil(Map.get(conn.assigns, :user)) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/lucidboard_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", LucidboardWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | def connect(_params, socket, _connect_info) do 19 | {:ok, socket} 20 | end 21 | 22 | # Socket id's are topics that allow you to identify all sockets for a given user: 23 | # 24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 25 | # 26 | # Would allow you to broadcast a "disconnect" event and terminate 27 | # all active sockets and channels for a given user: 28 | # 29 | # LucidboardWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 30 | # 31 | # Returning `nil` makes this socket anonymous. 32 | def id(_socket), do: nil 33 | end 34 | -------------------------------------------------------------------------------- /lib/lucidboard_web/controllers/auth_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.AuthController do 2 | @moduledoc """ 3 | Auth controller responsible for handling Ueberauth responses 4 | """ 5 | use LucidboardWeb, :controller 6 | alias Lucidboard.Account 7 | alias LucidboardWeb.{BoardLive, DashboardLive} 8 | alias LucidboardWeb.Router.Helpers, as: Routes 9 | alias Ueberauth.Strategy.Helpers 10 | 11 | plug(Ueberauth) 12 | 13 | def request(conn, _params) do 14 | render(conn, "request.html", callback_url: Helpers.callback_url(conn)) 15 | end 16 | 17 | def dumb_signin(conn, %{"signin" => %{"username" => username}}) do 18 | board_id = get_session(conn, :signin_board_id) 19 | 20 | if Lucidboard.auth_provider() != :dumb do 21 | {:error, :not_found} 22 | else 23 | case Account.by_username(username) do 24 | nil -> 25 | {:ok, user} = 26 | Account.create(name: username, full_name: "Mister #{username}") 27 | 28 | conn 29 | |> put_session(:user_id, user.id) 30 | |> put_session(:signin_board_id, nil) 31 | |> put_flash(:info, """ 32 | We've created your account and you're now signed in! 33 | """) 34 | |> redirect(to: get_redirect_path(conn, board_id)) 35 | 36 | user -> 37 | conn 38 | |> put_session(:user_id, user.id) 39 | |> put_session(:signin_board_id, nil) 40 | |> put_flash(:info, "You have successfully signed in!") 41 | |> redirect(to: get_redirect_path(conn, board_id)) 42 | end 43 | end 44 | end 45 | 46 | def signout(conn, _params) do 47 | conn 48 | |> put_flash(:info, "You have been signed out!") 49 | |> configure_session(drop: true) 50 | |> redirect(to: "/") 51 | end 52 | 53 | def callback(%{assigns: %{ueberauth_failure: _fails}} = conn, _params) do 54 | conn 55 | |> put_flash(:error, "Failed to authenticate.") 56 | |> redirect(to: "/") 57 | end 58 | 59 | def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do 60 | board_id = get_session(conn, :signin_board_id) 61 | 62 | case Account.auth_to_user(auth) do 63 | {:ok, user} -> 64 | conn 65 | |> put_session(:user_id, user.id) 66 | |> put_session(:signin_board_id, nil) 67 | |> put_flash(:info, "Hello, #{user.name}!") 68 | |> redirect(to: get_redirect_path(conn, board_id)) 69 | 70 | {:error, reason} -> 71 | conn 72 | |> put_flash(:error, reason) 73 | |> redirect(to: "/") 74 | end 75 | end 76 | 77 | defp get_redirect_path(conn, nil), 78 | do: Routes.live_path(conn, DashboardLive) 79 | 80 | defp get_redirect_path(conn, board_id), 81 | do: Routes.live_path(conn, BoardLive, board_id) 82 | end 83 | -------------------------------------------------------------------------------- /lib/lucidboard_web/controllers/board_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.BoardController do 2 | use LucidboardWeb, :controller 3 | alias Lucidboard.{Account, LiveBoard} 4 | 5 | def dnd_into_junction(%{body_params: p} = conn, %{"id" => board_id}) do 6 | user = conn |> get_session(:user_id) |> Account.get!() 7 | 8 | args = %{ 9 | id: p["what_id"], 10 | col_id: p["col_id"], 11 | pos: String.to_integer(p["pos"]) 12 | } 13 | 14 | action = 15 | case p["what"] do 16 | "card" -> {:move_card_to_junction, args} 17 | "pile" -> {:move_pile_to_junction, args} 18 | end 19 | 20 | do_liveboard_action(board_id, action, user) 21 | 22 | resp(conn, 200, "ok") 23 | end 24 | 25 | # When a pile is dragged onto a pile, p["what"] is "pile", and we straight 26 | # ignore it. Unsupported action. 27 | def dnd_into_pile(%{body_params: p} = conn, %{"id" => board_id}) do 28 | user = conn |> get_session(:user_id) |> Account.get!() 29 | 30 | if "card" == p["what"] do 31 | action = {:move_card_to_pile, id: p["what_id"], pile_id: p["pile_id"]} 32 | do_liveboard_action(board_id, action, user) 33 | end 34 | 35 | resp(conn, 200, "ok") 36 | end 37 | 38 | defp do_liveboard_action(board_id, action, user) do 39 | msg = {:action, action, user: user} 40 | {:ok, _} = LiveBoard.call(String.to_integer(board_id), msg) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/lucidboard_web/controllers/fallback_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.FallbackController do 2 | use Phoenix.Controller 3 | alias LucidboardWeb.ErrorView 4 | require Logger 5 | 6 | def call(conn, {:error, :not_found}) do 7 | conn 8 | |> put_status(:not_found) 9 | |> put_view(ErrorView) 10 | |> render(:"404") 11 | end 12 | 13 | def call(conn, {:error, :unauthorized}) do 14 | conn 15 | |> put_status(403) 16 | |> put_view(ErrorView) 17 | |> render(:"403") 18 | end 19 | 20 | def call(conn, {redirect_type, location}) 21 | when redirect_type in [:see_other, :moved_permanently] do 22 | conn 23 | |> put_status(redirect_type) 24 | |> redirect(to: location) 25 | end 26 | 27 | def call(conn, {:error, message}) do 28 | Logger.error("Error on #{conn.request_path}: #{message}") 29 | 30 | conn 31 | |> put_status(500) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/lucidboard_web/controllers/page_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.PageController do 2 | use LucidboardWeb, :controller 3 | alias LucidboardWeb.LayoutView 4 | 5 | def index(conn, _params) do 6 | render(conn, "index.html", layout: {LayoutView, :full_width}) 7 | end 8 | 9 | def changelog(conn, _params) do 10 | render(conn, "changelog.html") 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/lucidboard_web/controllers/user_controller.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.UserController do 2 | use LucidboardWeb, :controller 3 | alias Ecto.Changeset 4 | alias Lucidboard.{Repo, User} 5 | alias LucidboardWeb.DashboardLive 6 | alias LucidboardWeb.Router.Helpers, as: Routes 7 | 8 | @themes Application.get_env(:lucidboard, :themes) 9 | 10 | def signin(conn, params) do 11 | if signed_in?(conn) do 12 | conn 13 | |> put_status(:see_other) 14 | |> redirect(to: Routes.live_path(conn, DashboardLive)) 15 | else 16 | board_id = Map.get(params, "board_id", nil) 17 | conn = put_session(conn, :signin_board_id, board_id) 18 | render(conn, "signin.html", board_id: board_id) 19 | end 20 | end 21 | 22 | def settings(conn, _params) do 23 | render(conn, "settings.html", user: conn.assigns[:user], themes: @themes) 24 | end 25 | 26 | def update_settings(conn, params) do 27 | with %{valid?: true} = u_cs <- 28 | conn.assigns.user 29 | |> User.changeset(%{"settings" => params}) 30 | |> Changeset.cast_embed(:settings), 31 | {:ok, new_user} <- Repo.update(u_cs) do 32 | conn 33 | |> assign(:user, new_user) 34 | |> put_flash(:info, "Your settings have been saved.") 35 | |> put_status(:see_other) 36 | |> redirect(to: Routes.user_path(conn, :settings)) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/lucidboard_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :lucidboard 3 | 4 | socket("/live", Phoenix.LiveView.Socket) 5 | 6 | plug(Plug.Static, 7 | at: "/", 8 | from: :lucidboard, 9 | only: ~w(css fonts images js favicon.ico robots.txt) 10 | ) 11 | 12 | # Code reloading can be explicitly enabled under the 13 | # :code_reloader configuration of your endpoint. 14 | if code_reloading? do 15 | socket("/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket) 16 | plug(Phoenix.LiveReloader) 17 | plug(Phoenix.CodeReloader) 18 | end 19 | 20 | plug(Plug.RequestId) 21 | plug(Plug.Logger) 22 | 23 | plug(Plug.Parsers, 24 | parsers: [:urlencoded, :multipart, :json], 25 | pass: ["*/*"], 26 | json_decoder: Phoenix.json_library() 27 | ) 28 | 29 | plug(Plug.MethodOverride) 30 | plug(Plug.Head) 31 | 32 | # The session will be stored in the cookie and signed, 33 | # this means its contents can be read but not tampered with. 34 | # Set :encryption_salt if you would also like to encrypt it. 35 | plug(Plug.Session, 36 | store: :cookie, 37 | key: "_lucidboard_key", 38 | signing_salt: "9XKqGQ9H" 39 | ) 40 | 41 | plug(LucidboardWeb.Router) 42 | end 43 | -------------------------------------------------------------------------------- /lib/lucidboard_web/gettext.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.Gettext do 2 | @moduledoc """ 3 | A module providing Internationalization with a gettext-based API. 4 | 5 | By using [Gettext](https://hexdocs.pm/gettext), 6 | your module gains a set of macros for translations, for example: 7 | 8 | import LucidboardWeb.Gettext 9 | 10 | # Simple translation 11 | gettext("Here is the string to translate") 12 | 13 | # Plural translation 14 | ngettext("Here is the string to translate", 15 | "Here are the strings to translate", 16 | 3) 17 | 18 | # Domain-based translation 19 | dgettext("errors", "Here is the error message to translate") 20 | 21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. 22 | """ 23 | use Gettext, otp_app: :lucidboard 24 | end 25 | -------------------------------------------------------------------------------- /lib/lucidboard_web/live/board_live/search.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.BoardLive.Search do 2 | @moduledoc "The on-board search results" 3 | alias Lucidboard.Board 4 | alias Lucidboard.Twiddler.{Glass, Op} 5 | 6 | @type t :: %__MODULE__{ 7 | q: String.t(), 8 | board: Board.t() 9 | } 10 | 11 | defstruct [:q, :board] 12 | 13 | # credo:disable-for-lines:10 Credo.Check.Refactor.Nesting 14 | @spec query(String.t() | t, Board.t()) :: Board.t() 15 | def query(%__MODULE__{q: q}, board) do 16 | query(q, board) 17 | end 18 | 19 | def query(q, board) do 20 | Enum.reduce(board.columns, board, fn col, acc_board -> 21 | Enum.reduce(col.piles, acc_board, fn pile, acc_board2 -> 22 | Enum.reduce(pile.cards, acc_board2, fn card, acc_board3 -> 23 | do_reduce_by_query(acc_board3, card, q) 24 | end) 25 | end) 26 | end) 27 | end 28 | 29 | defp do_reduce_by_query(board, card, q) do 30 | match = 31 | if Regex.match?(~r/[A-Z]/, q) do 32 | String.contains?(card.body, q) 33 | else 34 | String.contains?(String.downcase(card.body), String.downcase(q)) 35 | end 36 | 37 | if match do 38 | board 39 | else 40 | with {:ok, card_path} <- Glass.card_path_by_id(board, card.id), 41 | {:ok, new_board, _card, _tx_fn} <- Op.cut_card(board, card_path) do 42 | new_board 43 | else 44 | bad -> raise "Unexpected return!: #{inspect(bad)}" 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/lucidboard_web/live/create_board_live.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.CreateBoardLive do 2 | @moduledoc "The LiveView for the create board screen" 3 | use Phoenix.LiveView 4 | alias Ecto.Changeset 5 | alias Lucidboard.{Account, Board, BoardSettings, Column, Twiddler} 6 | alias LucidboardWeb.{BoardLive, BoardView, Endpoint} 7 | alias LucidboardWeb.Router.Helpers, as: Routes 8 | 9 | @templates Application.get_env(:lucidboard, :templates) 10 | 11 | def render(assigns) do 12 | BoardView.render("create_board.html", assigns) 13 | end 14 | 15 | def mount(%{user_id: nil}, socket) do 16 | socket = 17 | socket 18 | |> put_flash(:error, "You must be signed in") 19 | |> redirect(to: Routes.user_path(Endpoint, :signin)) 20 | 21 | {:stop, socket} 22 | end 23 | 24 | def mount(params, socket) do 25 | template_options = 26 | for %{name: name, columns: columns} <- @templates do 27 | {"#{name} (#{Enum.join(columns, ", ")})", name} 28 | end 29 | 30 | socket = 31 | socket 32 | |> assign(:user, params.user_id && Account.get(params.user_id)) 33 | |> assign(:template_options, template_options) 34 | |> assign( 35 | :board_changeset, 36 | Board.changeset(Board.new()) 37 | ) 38 | 39 | {:ok, socket} 40 | end 41 | 42 | def handle_event("create", %{"board" => params}, socket) do 43 | {columns, settings} = 44 | case Enum.find(@templates, fn t -> t.name == params["template"] end) do 45 | nil -> 46 | {nil, nil} 47 | 48 | tpl -> 49 | { 50 | Enum.map(Enum.with_index(tpl.columns), fn {c, idx} -> 51 | Column.new([title: c, pos: idx], :just_map) 52 | end), 53 | BoardSettings.new( 54 | tpl.settings ++ 55 | [ 56 | access: params["access"], 57 | anonymous: params["anonymous"] == "1" 58 | ], 59 | :just_map 60 | ) 61 | } 62 | end 63 | 64 | [ 65 | title: params["title"], 66 | columns: columns, 67 | settings: settings, 68 | user: socket.assigns.user 69 | ] 70 | |> Board.new() 71 | |> Twiddler.insert() 72 | |> case do 73 | {:error, %Changeset{} = cs} -> 74 | {:noreply, assign(socket, :board_changeset, cs)} 75 | 76 | {:ok, %Board{id: id}} -> 77 | {:noreply, 78 | redirect(socket, to: Routes.live_path(socket, BoardLive, id))} 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/lucidboard_web/live/dashboard_live.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.DashboardLive do 2 | @moduledoc "The LiveView for the dashboard page" 3 | use Phoenix.LiveView 4 | alias Lucidboard.{ShortBoard, Twiddler} 5 | alias LucidboardWeb.{DashboardView, Endpoint} 6 | alias LucidboardWeb.Router.Helpers, as: Routes 7 | 8 | def render(assigns) do 9 | DashboardView.render("index.html", assigns) 10 | end 11 | 12 | def mount(%{user_id: nil}, socket) do 13 | socket = 14 | socket 15 | |> put_flash(:error, "You must be signed in") 16 | |> redirect(to: Routes.user_path(Endpoint, :signin)) 17 | 18 | {:stop, socket} 19 | end 20 | 21 | def mount(%{user_id: user_id}, socket) do 22 | Lucidboard.subscribe("dashboards") 23 | 24 | socket = 25 | socket 26 | |> assign( 27 | user_id: user_id, 28 | subscriptions: MapSet.new(), 29 | boards_filter: "all" 30 | ) 31 | |> load_data_and_handle_subscriptions() 32 | 33 | {:ok, socket} 34 | end 35 | 36 | def handle_info(:full_reload, socket) do 37 | {:noreply, load_data_and_handle_subscriptions(socket)} 38 | end 39 | 40 | def handle_info({:short_board, short_board}, socket) do 41 | short_boards = 42 | socket.assigns.short_boards 43 | |> Enum.find_index(fn sb -> sb.id == short_board.id end) 44 | |> case do 45 | nil -> socket.assigns.short_boards 46 | idx -> List.replace_at(socket.assigns.short_boards, idx, short_board) 47 | end 48 | 49 | {:noreply, assign(socket, :short_boards, short_boards)} 50 | end 51 | 52 | def handle_event( 53 | "search", 54 | %{"q" => search_key, "boards_filter" => boards_filter}, 55 | socket 56 | ) do 57 | {:noreply, 58 | load_data_and_handle_subscriptions(socket, 0, search_key, boards_filter)} 59 | end 60 | 61 | def handle_event("paginate", direction, socket) do 62 | socket = 63 | load_data_and_handle_subscriptions( 64 | socket, 65 | if(direction == "prev", do: -1, else: 1), 66 | socket.assigns.search_key, 67 | socket.assigns.boards_filter 68 | ) 69 | 70 | {:noreply, socket} 71 | end 72 | 73 | # Loads all dashboard data and updates subscriptions to reflect the visible 74 | # boards. 75 | defp load_data_and_handle_subscriptions( 76 | socket, 77 | page_direction \\ 0, 78 | q \\ nil, 79 | boards_filter \\ nil 80 | ) do 81 | search_key = q || socket.assigns[:search_key] 82 | boards_filter = boards_filter || socket.assigns[:boards_filter] 83 | 84 | board_pagination = 85 | Twiddler.boards( 86 | socket.assigns.user_id, 87 | (get_page_number(socket) || 1) + page_direction, 88 | search_key, 89 | boards_filter 90 | ) 91 | 92 | short_boards = Enum.map(board_pagination, &ShortBoard.from_board/1) 93 | new_subscriptions = short_boards |> Enum.map(& &1.id) |> MapSet.new() 94 | orig_subscriptions = socket.assigns.subscriptions 95 | 96 | Enum.each(MapSet.difference(orig_subscriptions, new_subscriptions), fn id -> 97 | Lucidboard.unsubscribe("short_board:#{id}") 98 | end) 99 | 100 | Enum.each(MapSet.difference(new_subscriptions, orig_subscriptions), fn id -> 101 | Lucidboard.subscribe("short_board:#{id}") 102 | end) 103 | 104 | assign(socket, 105 | short_boards: short_boards, 106 | board_pagination: board_pagination, 107 | subscriptions: new_subscriptions, 108 | search_key: search_key, 109 | boards_filter: boards_filter 110 | ) 111 | end 112 | 113 | defp get_page_number(%{assigns: %{board_pagination: %{page_number: num}}}), 114 | do: num 115 | 116 | defp get_page_number(_), do: nil 117 | end 118 | -------------------------------------------------------------------------------- /lib/lucidboard_web/live/live_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.LiveHelper do 2 | @moduledoc """ 3 | Some functionality to share between all Lucidboard LiveViews 4 | """ 5 | import Phoenix.LiveView, only: [assign: 3] 6 | alias Phoenix.LiveView.Socket 7 | 8 | @flash_timeout 5_000 9 | 10 | defmacro __using__(_args) do 11 | quote do 12 | import unquote(__MODULE__), only: [put_the_flash: 3] 13 | import Phoenix.LiveView, only: [assign: 3] 14 | 15 | def handle_info(:clear_flash, socket) do 16 | {:noreply, 17 | socket |> assign(:flash_type, nil) |> assign(:flash_msg, nil)} 18 | end 19 | end 20 | end 21 | 22 | @spec put_the_flash(Socket.t(), :info | :error, String.t()) :: Socket.t() 23 | def put_the_flash(%Socket{} = socket, type, msg) 24 | when type in [:info, :error] do 25 | Process.send_after(self(), :clear_flash, @flash_timeout) 26 | 27 | socket 28 | |> assign(:flash_type, type) 29 | |> assign(:flash_msg, msg) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/lucidboard_web/plugs/load_user_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.LoadUserPlug do 2 | @moduledoc "Load the User struct (or `nil`) into conn.assigns." 3 | import Ecto.Query 4 | import Plug.Conn 5 | alias Lucidboard.{Repo, User} 6 | 7 | @default_theme Application.get_env(:lucidboard, :default_theme) 8 | 9 | def init(opts), do: opts 10 | 11 | def call(conn, _opts) do 12 | {conn, user} = 13 | case get_session(conn, :user_id) do 14 | nil -> 15 | {conn, nil} 16 | 17 | user_id -> 18 | user = Repo.one(from(u in User, where: u.id == ^user_id)) 19 | {conn, user} 20 | end 21 | 22 | conn 23 | |> assign(:user, user) 24 | |> assign_theme() 25 | end 26 | 27 | defp assign_theme(conn) do 28 | user = conn.assigns[:user] 29 | 30 | theme_css = 31 | if is_nil(user) or user.settings.theme in ["default", nil], 32 | do: @default_theme, 33 | else: user.settings.theme 34 | 35 | assign(conn, :theme_css, theme_css <> ".css") 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/lucidboard_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.Router do 2 | use LucidboardWeb, :router 3 | alias LucidboardWeb.LayoutView 4 | 5 | pipeline :browser do 6 | plug(:accepts, ["html"]) 7 | plug(:fetch_session) 8 | plug(:fetch_flash) 9 | plug(Phoenix.LiveView.Flash) 10 | plug(LucidboardWeb.LoadUserPlug) 11 | plug(:protect_from_forgery) 12 | plug(:put_secure_browser_headers) 13 | plug(:put_layout, {LayoutView, :normal}) 14 | end 15 | 16 | pipeline :api do 17 | plug(:accepts, ["json"]) 18 | end 19 | 20 | scope "/auth", LucidboardWeb do 21 | pipe_through([:browser]) 22 | 23 | get("/:provider", AuthController, :request) 24 | get("/:provider/callback", AuthController, :callback) 25 | end 26 | 27 | scope "/", LucidboardWeb do 28 | pipe_through(:browser) 29 | 30 | get("/", PageController, :index) 31 | get("/changelog", PageController, :changelog) 32 | 33 | get("/signin", UserController, :signin) 34 | post("/signin", AuthController, :dumb_signin) 35 | get("/signout", AuthController, :signout) 36 | 37 | get("/user-settings", UserController, :settings) 38 | post("/user-settings", UserController, :update_settings) 39 | 40 | live("/dashboard", DashboardLive, session: [:user_id]) 41 | live("/create-board", CreateBoardLive, session: [:path_params, :user_id]) 42 | 43 | live("/boards", DashboardLive, session: [:user_id]) 44 | live("/boards/:id", BoardLive, session: [:path_params, :user_id]) 45 | 46 | post("/boards/:id/dnd-into-junction", BoardController, :dnd_into_junction) 47 | post("/boards/:id/dnd-into-pile", BoardController, :dnd_into_pile) 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/_access_field.html.eex: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/_roles.html.eex: -------------------------------------------------------------------------------- 1 | <% more_than_one_owner? = more_than_one_owner?(@board.board_roles) %> 2 | 3 | <%= if @editable do %> 4 |
8 | 11 |
12 | 20 | disabled="true" 21 | <% end %> 22 | /> 23 |
24 | 28 |
29 |
30 | 31 | <%= if has_role?(@user, @board, :owner) do %> 32 | 33 | <%= for user <- @role_suggest.list do %> 34 | 35 | <% end %> 36 | 37 | 38 | 39 | <% end %> 40 |
41 | <% end %> 42 | 56 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/board.html.eex: -------------------------------------------------------------------------------- 1 | <% 2 | board = if @search, do: @search.board, else: @board 3 | 4 | {user_locked_card_id, locked_card_ids} = 5 | locked_cards(@user.id, board.id, @socket.id) 6 | %> 7 |
8 | <%= for column <- board.columns do %> 9 |
10 |
11 |

<%= column.title %>

12 | 13 | 14 | Sort by Likes 15 | 16 |
17 | 18 | <%= for pile <- column.piles do %> 19 |
23 | onDragOver="dnd.allowDrop(event)" 24 | onDragLeave="dnd.dragLeave(event)" 25 | onDrop="dnd.dropIntoJunction(event)" 26 | <% end %> 27 | > 28 |   29 |
30 | 31 | <%= render( 32 | LucidboardWeb.BoardView, 33 | "pile.html", 34 | Map.merge(assigns, %{ 35 | pile: pile, 36 | locked_card_id: user_locked_card_id, 37 | locked_card_ids: locked_card_ids, 38 | }) 39 | ) %> 40 | <% end %> 41 | 42 |
46 | onDragOver="dnd.allowDrop(event)" 47 | onDragLeave="dnd.dragLeave(event)" 48 | onDrop="dnd.dropIntoJunction(event)" 49 | <% end %> 50 | > 51 |   52 |
53 | 54 |

55 | <%= show_card_count(column) %> 56 |

57 | 58 | <%= unless @search || user_locked_card_id do %> 59 | 63 | <%= fas("plus") %>Add Card 64 | 65 | <% end %> 66 |
67 | <% end %> 68 |
69 | 70 | <% title = Map.get(@board, :title) %> 71 | <%= if title, do: raw(~s()) %> -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/card.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= cond do %> 3 | <% @locked_by_user? -> %> 4 | <%= form_for @card_changeset, "#", [phx_submit: :card_save], fn f -> %> 5 | <%= textarea f, :body, class: "textarea is-medium has-fixed-size lb-textarea u-Phm u-Ptm", id: "txtarea" %> 6 | <%= error_tag f, :body %> 7 |
8 | <%= submit "Save", phx_disable_with: "Saving...", class: "button lb-button is-primary" %> 9 | <%= submit "Cancel", phx_click: :card_cancel, class: "button lb-button" %> 10 |
11 | <% end %> 12 | 13 | <% @locked? -> %> 14 | 15 | 16 | <% true -> %> 17 |

19 | phx-click="inline_edit" 20 | phx-value="<%= @card.id %>" 21 | <% end %> 22 | class="content is-size-<%= card_body_size_by_copy(@card.body) %> js-inlineEdit u-Phm u-Ptm"> 23 | <%= 24 | @card.body 25 | |> String.split("\n", trim: false) 26 | |> Enum.intersperse(Phoenix.HTML.Tag.tag(:br)) 27 | %> 28 |

29 | <% end %> 30 |
31 | 32 | <%= unless @locked? do %> 33 | 70 | <% end %> 71 | 72 | <%= if @delete_confirming_card_id == @card.id do %> 73 |
74 |

75 | If you delete the card, it's gone forever. Are you sure? 76 |

77 |
78 | 79 | 80 |
81 |
82 | <% end %> 83 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/create_board.html.leex: -------------------------------------------------------------------------------- 1 | <% 2 | target = Routes.live_path(LucidboardWeb.Endpoint, LucidboardWeb.CreateBoardLive) 3 | opts = [phx_submit: :create] 4 | %> 5 |
6 |

Create a Lucidboard

7 | 8 |
9 |
10 | <%= form_for @board_changeset, target, opts, fn f -> %> 11 |
12 |
13 | <%= text_input(f, :title, 14 | class: "lb-input input is-medium", 15 | placeholder: "Title") %> 16 | <%= error_tag(f, :title) %> 17 |
18 |
19 | 20 |
21 | 22 |
23 |
24 | <%= select(f, :template, @template_options) %> 25 | <%= error_tag(f, :template) %> 26 |
27 |
28 |
29 | 30 |
31 | 32 | <%= render(LucidboardWeb.BoardView, "_access_field.html", f: f) %> 33 |
34 | 35 |
36 | 37 |
    38 |
  • 39 | <%= radio_button(f, :anonymous, "1", class: "lb-radioButton__input", checked: "checked", id: "board_settings_anonymous_true") %> 40 | <%= label f, :anonymous, class: "lb-radioButton__label", for: "board_settings_anonymous_true" do %> 41 | Yes — User actions are anonymous 42 | <% end %> 43 |
  • 44 |
  • 45 | <%= radio_button(f, :anonymous, "0", class: "lb-radioButton__input", id: "board_settings_anonymous_false") %> 46 | <%= label f, :anonymous, class: "lb-radioButton__label", for: "board_settings_anonymous_false" do %> 47 | No — Usernames are attached to actions 48 | <% end %> 49 |
  • 50 |
51 |
52 | 53 |
54 |
55 | 58 |
59 |
60 | 61 | <% end %> 62 |
63 |
64 |
65 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/events.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 | 8 |
9 |
10 |

Users Online

11 | 18 |
19 |
20 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/index.html.leex: -------------------------------------------------------------------------------- 1 |
2 | <%= if assigns[:flash_msg] do %> 3 | <% class = if assigns[:flash_type] == :info, do: "is-success", else: "is-danger" %> 4 | 7 | <% end %> 8 | 9 |
10 |
11 | <%= if @board_changeset && has_role?(@user, @board, :owner) do %> 12 | <%= form_for @board_changeset, "#", [phx_submit: :board_name_save], fn f -> %> 13 |
14 | <%= raw board_access_icon(@board.settings.access) %> 15 |
16 | <%= text_input f, :title, class: "input lb-input lb-input--alt is-medium", maxlength: 255 %> 17 | <%= error_tag f, :title %> 18 |
19 |
20 | <%= submit "Save", phx_disable_with: "Saving...", class: "button lb-button is-primary" %> 21 | 22 |
23 |
24 | <% end %> 25 | <% else %> 26 | <%= if has_role?(@user, @board, :owner) do %> 27 |

28 | <% else %> 29 |

30 | <% end %> 31 | <%= raw board_access_icon(@board.settings.access, "lb-icon--fa u-Mrs") %> 32 | <%= @board.title %> 33 |   34 |

35 | <% end %> 36 |
37 | 38 |
39 | 76 |
77 |
78 | 79 | <%= 80 | case @tab do 81 | :board -> 82 | render(LucidboardWeb.BoardView, "board.html", assigns) 83 | 84 | :events -> 85 | render(LucidboardWeb.BoardView, "events.html", assigns) 86 | 87 | :options -> 88 | if has_role?(@user, @board, :owner) do 89 | render(LucidboardWeb.BoardView, "options.html", assigns) 90 | else 91 | render(LucidboardWeb.BoardView, "options-show.html", assigns) 92 | end 93 | end 94 | %> 95 | 96 | <%= 97 | if @modal_open? do 98 | render(LucidboardWeb.BoardView, "modal_card.html", assigns) 99 | end 100 | %> 101 |
102 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/modal_card.html.eex: -------------------------------------------------------------------------------- 1 | 52 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/options-show.html.eex: -------------------------------------------------------------------------------- 1 | <% settings = Ecto.Changeset.apply_changes(@board_settings_changeset) %> 2 |
3 |
4 |
5 |

Board Settings

6 |

Likes Per User: <%= settings.likes_per_user %>

7 |

Likes Per User Per Card: <%= settings.likes_per_user_per_card %>

8 |
9 | 10 |
11 |

Roles

12 | <%= render( 13 | LucidboardWeb.BoardView, 14 | "_roles.html", 15 | board: @board, editable: false) %> 16 | 17 |

 

18 | 19 |

(This board is <%= settings.access %>.)

20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/board/pile.html.leex: -------------------------------------------------------------------------------- 1 | <% 2 | card = hd(@pile.cards) 3 | card_locked? = card.id in @locked_card_ids 4 | 5 | rendered_card = render( 6 | LucidboardWeb.BoardView, 7 | "card.html", 8 | Map.merge(assigns, %{ 9 | card: card, 10 | locked_by_user?: @locked_card_id == card.id, 11 | locked?: card_locked? 12 | }) 13 | ) 14 | %> 15 | 16 | <%= if length(@pile.cards) == 1 do %> 17 |
19 |
24 | draggable="true" 25 | onDragStart="dnd.drag(event)" 26 | onDragOver="dnd.allowDrop(event)" 27 | onDrop="dnd.dropIntoPile(event)" 28 | <% end %> 29 | > 30 | <%= rendered_card %> 31 |
32 |
33 | <% else %> 34 |
39 | draggable="true" 40 | onDragStart="dnd.drag(event)" 41 | onDragOver="dnd.allowDrop(event)" 42 | onDrop="dnd.dropIntoPile(event)" 43 | <% end %> 44 | > 45 |
48 | draggable="true" 49 | onDragStart="dnd.drag(event)" 50 | class="stack stack--column" 51 | <% end %> 52 | > 53 | <%= rendered_card %> 54 |
55 | 61 |
62 | <% end %> 63 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/dashboard/index.html.leex: -------------------------------------------------------------------------------- 1 |
2 | 4 | <%= fas("plus") %>Create Board 5 | 6 | 7 |

Lucidboards

8 | 9 | 35 | 36 | <%= for short_board <- @short_boards do %> 37 | 39 |
40 |

41 | <%= raw board_access_icon(short_board.access, "lb-icon--fa u-Mrs") %> 42 | <%= short_board.title %> 43 |

44 | Created by <%= short_board.username %> at 45 | 46 | <%= utc_to_formatted(short_board.inserted_at, :short) %>. 47 | <%= if short_board.last_event do %> 48 | 49 | <%= fas("clock") %> 50 | <%= display_event(short_board.last_event, short_board.anonymous) %> 51 | 52 | <% end %> 53 |
54 | <%#
%> 57 | 58 | <% end %> 59 | 60 | <%= if @board_pagination.total_entries > 0 do %> 61 |
62 |
63 |

64 | Showing page <%= @board_pagination.page_number %> of <%= @board_pagination.total_pages %> 65 |

66 |

67 | Total boards: <%= @board_pagination.total_entries %> 68 |

69 |
70 |
71 | <%= if @board_pagination.page_number > 1 do %> 72 | 73 | <% end %> 74 | 75 | <%= if @board_pagination.page_number < @board_pagination.total_pages do %> 76 | 77 | <% end %> 78 |
79 |
80 | <% end %> 81 | 82 | <%= if @short_boards == [] do %> 83 | No Lucidboards! 84 | <% end %> 85 |
86 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/layout/accoutrements/footer.html.eex: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/layout/accoutrements/header.html.eex: -------------------------------------------------------------------------------- 1 |
2 | 38 |
39 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/layout/accoutrements/navbar_menu.html.eex: -------------------------------------------------------------------------------- 1 | <%= if @user do %> 2 | 3 | Dashboard 4 | 5 | 6 | Create Board 7 | 8 | <% end %> -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/layout/full_width.html.eex: -------------------------------------------------------------------------------- 1 | <%= render_layout "app.html", assigns do %> 2 | 3 | 4 | <%= render @view_module, @view_template, assigns %> 5 | <% end %> -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/layout/header_user_menu.html.eex: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/layout/normal.html.eex: -------------------------------------------------------------------------------- 1 | <%= render_layout "app.html", assigns do %> 2 |
3 |
4 | 5 | 6 |
7 | <%= render @view_module, @view_template, assigns %> 8 |
9 | <% end %> 10 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/page/index.html.eex: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

5 | It's Happening Again! 6 |

7 |

8 | Lucidboard is a kanban tool for organizing ideas. 9 |

10 |
11 |
12 |
13 | 14 |
15 | <%= unless signed_in?(@conn) do %> 16 |

17 | <%= login_button() %> 18 |

19 | <% end %> 20 | 21 |

A tool...

22 | 23 |

Lucidboard is a tool for teams or individuals looking to:

24 | 25 | 31 | 32 |

We Want Your Feedback!

33 | 34 |

Have an idea for Lucidboard? Please create an 36 | issue on Github or email Adam 37 | so we can discuss! If it makes sense, we will get it prioritized & 38 | implemented!

39 | 40 |

Find the Code on Github!

41 | 42 |

Lucidboard is 43 | MIT-licensed on Github.com. Pull requests welcome! (See the README for 44 | instructions that should get you a working dev environment in minutes.)

45 |
46 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/user/settings.html.eex: -------------------------------------------------------------------------------- 1 |
2 |

User Settings

3 | 4 |
5 |
6 | <%= form_for @conn, Routes.user_path(@conn, :update_settings), fn f -> %> 7 |
8 | 9 |
10 |
11 | <%= select(f, :theme, @themes, selected: @user.settings.theme) %> 12 |
13 |
14 |
15 | 16 |
17 |
18 | 19 |
20 |
21 | 22 | <% end %> 23 |
24 |
25 |
26 | -------------------------------------------------------------------------------- /lib/lucidboard_web/templates/user/signin.html.eex: -------------------------------------------------------------------------------- 1 |
2 | <%= if Lucidboard.auth_provider() == :dumb do %> 3 |
4 | <%= form_for @conn, Routes.user_path(@conn, :signin), [as: :signin], fn f -> %> 5 |
6 |

Sign In

7 |
8 | 9 |

10 | In an effort to make a dev environment easy to get started, 11 | Lucidboard is currently using the "dumb" (default) authentication 12 | method. Simply enter a username and you will be logged in as that 13 | user. See the documentation about the other methods available and 14 | how to set them up. 15 |

16 | 17 |
18 | <%= label f, :username, "Username", class: "control-label" %> 19 | <%= text_input f, :username, class: "form-control", placeholder: "Username", required: true, autofocus: true %> 20 |
21 | 22 | <%= submit "Sign in" %> 23 | <% end %> 24 |
25 | <% else %> 26 | <%= login_button() %> 27 | <% end %> 28 |
29 | -------------------------------------------------------------------------------- /lib/lucidboard_web/view_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.ViewHelper do 2 | @moduledoc "Helper functions for all views" 3 | import Phoenix.HTML 4 | alias Lucidboard.{BoardRole, Event} 5 | 6 | @doc "Create a font-awesome icon by name" 7 | def fas(name, class \\ nil), do: fa("fas", name, class) 8 | 9 | def fab(name, class \\ nil), do: fa("fab", name, class) 10 | 11 | @doc "Given a board access setting, return the icon HTML" 12 | def board_access_icon(type, class \\ nil) 13 | def board_access_icon("private", class), do: fas("lock", class) 14 | def board_access_icon("public", class), do: fas("eye", class) 15 | def board_access_icon(_, _), do: nil 16 | 17 | def show_card_count(column) do 18 | count = 19 | Enum.reduce(column.piles, 0, fn pile, acc -> 20 | acc + length(pile.cards) 21 | end) 22 | 23 | "#{count} card#{if count != 1, do: "s"}" 24 | end 25 | 26 | def card_body_size_by_copy(body) do 27 | chars = String.length(body) 28 | 29 | cond do 30 | chars < 20 -> "3" 31 | chars < 50 -> "4" 32 | true -> "5" 33 | end 34 | end 35 | 36 | @spec display_event(Event.t(), boolean) :: Phoenix.HTML.safe() 37 | def display_event( 38 | %Event{ 39 | inserted_at: inserted_at, 40 | user: %{name: name}, 41 | desc: desc 42 | }, 43 | anonymous 44 | ) do 45 | raw(""" 46 | #{Lucidboard.utc_to_formatted(inserted_at)} \ 47 | #{if anonymous, do: "a user", else: "#{name}"} \ 48 | #{desc}\ 49 | """) 50 | end 51 | 52 | defp fa(family, name, class) do 53 | extra = if is_nil(class), do: [], else: [class] 54 | full_class = Enum.join(["icon"] ++ extra, " ") 55 | 56 | raw(""" 57 | 58 | 59 | 60 | """) 61 | end 62 | 63 | def avatar(%{avatar_url: nil} = _user) do 64 | "user-circle" |> fas() |> raw() 65 | end 66 | 67 | def avatar(%{avatar_url: url}) do 68 | raw(""" 69 |
\ 70 | """) 71 | end 72 | 73 | @spec more_than_one_owner?([BoardRole.t()]) :: boolean 74 | def more_than_one_owner?(roles) do 75 | true == 76 | Enum.reduce_while(roles, 0, fn 77 | %{role: :owner}, 1 -> {:halt, true} 78 | %{role: :owner}, acc -> {:cont, acc + 1} 79 | _, acc -> {:cont, acc} 80 | end) 81 | end 82 | 83 | def login_button do 84 | raw( 85 | case Lucidboard.auth_provider() do 86 | :dumb -> 87 | ~E""" 88 | 89 | Sign in 90 | 91 | """ 92 | 93 | :github -> 94 | ~E""" 95 | 96 | Sign in with GitHub 97 | 98 | """ 99 | 100 | :pingfed -> 101 | ~E""" 102 | 103 | Sign in with PingFed 104 | 105 | """ 106 | end 107 | ) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/lucidboard_web/views/board_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.BoardView do 2 | use LucidboardWeb, :view 3 | alias Lucidboard.Presence 4 | alias LucidboardWeb.BoardLive 5 | 6 | def user_id(:unset), do: 1 7 | def user_id(assigns), do: assigns.user.id 8 | def board_id(:unset), do: 1 9 | def board_id(assigns), do: assigns.board.id 10 | 11 | def online_indicator({_user_id, map} = _presence_item) do 12 | list = Map.get(map, :metas) 13 | name = list |> hd() |> Map.get(:name) 14 | 15 | if length(list) > 1, 16 | do: "#{name} (#{length(list)})", 17 | else: name 18 | end 19 | 20 | @doc """ 21 | Get the user's session's locked card id and a list of all locked card ids 22 | """ 23 | @spec locked_cards(integer, integer, String.t() | nil) :: 24 | {integer | nil, [integer]} 25 | def locked_cards(user_id, board_id, socket_id \\ nil) 26 | 27 | # def locked_cards(_user_id, board_id, socket_id) do 28 | # list = board_id |> BoardLive.topic() |> Presence.list() 29 | # {nil, locked_card_ids(list)} 30 | # end 31 | 32 | def locked_cards(user_id, board_id, socket_id) do 33 | list = board_id |> BoardLive.topic() |> Presence.list() 34 | 35 | user_locked_card_id = 36 | case Map.get(list, to_string(user_id)) do 37 | %{} = map -> 38 | map 39 | |> Map.get(:metas) 40 | |> Enum.find(fn x -> 41 | x.lv_ref == socket_id 42 | end) 43 | |> (fn map_maybe -> 44 | if is_map(map_maybe), 45 | do: Map.get(map_maybe, :locked_card_id), 46 | else: nil 47 | end).() 48 | 49 | _ -> 50 | nil 51 | end 52 | 53 | {user_locked_card_id, locked_card_ids(list)} 54 | end 55 | 56 | def locked_card_ids(presence_list) do 57 | Enum.reduce(presence_list, [], fn {_user_id, %{metas: metas}}, acc -> 58 | acc ++ 59 | Enum.reduce(metas, [], fn 60 | %{locked_card_id: card_id}, acc2 -> acc2 ++ [card_id] 61 | %{}, acc2 -> acc2 62 | end) 63 | end) 64 | end 65 | 66 | def count_user_likes(likes, user_id) do 67 | Enum.count(likes, fn like -> like.user_id == user_id end) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/lucidboard_web/views/dashboard_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.DashboardView do 2 | use LucidboardWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/lucidboard_web/views/error_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.ErrorHelpers do 2 | @moduledoc """ 3 | Conveniences for translating and building error messages. 4 | """ 5 | 6 | use Phoenix.HTML 7 | 8 | @doc """ 9 | Generates tag for inlined form input errors. 10 | """ 11 | def error_tag(form, field) do 12 | Enum.map(Keyword.get_values(form.errors, field), fn error -> 13 | content_tag(:span, translate_error(error), class: "lb-errorText") 14 | end) 15 | end 16 | 17 | @doc """ 18 | Translates an error message using gettext. 19 | """ 20 | def translate_error({msg, opts}) do 21 | # When using gettext, we typically pass the strings we want 22 | # to translate as a static argument: 23 | # 24 | # # Translate "is invalid" in the "errors" domain 25 | # dgettext("errors", "is invalid") 26 | # 27 | # # Translate the number of files with plural rules 28 | # dngettext("errors", "1 file", "%{count} files", count) 29 | # 30 | # Because the error messages we show in our forms and APIs 31 | # are defined inside Ecto, we need to translate them dynamically. 32 | # This requires us to call the Gettext module passing our gettext 33 | # backend as first argument. 34 | # 35 | # Note we use the "errors" domain, which means translations 36 | # should be written to the errors.po file. The :count option is 37 | # set by Ecto and indicates we should also apply plural rules. 38 | if count = opts[:count] do 39 | Gettext.dngettext(LucidboardWeb.Gettext, "errors", msg, msg, count, opts) 40 | else 41 | Gettext.dgettext(LucidboardWeb.Gettext, "errors", msg, opts) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/lucidboard_web/views/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.ErrorView do 2 | use LucidboardWeb, :view 3 | 4 | # If you want to customize a particular status code 5 | # for a certain format, you may uncomment below. 6 | # def render("500.html", _assigns) do 7 | # "Internal Server Error" 8 | # end 9 | 10 | # By default, Phoenix returns the status message from 11 | # the template name. For example, "404.html" becomes 12 | # "Not Found". 13 | def template_not_found(template, _assigns) do 14 | Phoenix.Controller.status_message_from_template(template) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/lucidboard_web/views/layout_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.LayoutView do 2 | use LucidboardWeb, :view 3 | 4 | @doc "Render our outer-most wrapping" 5 | def render_layout(outer_layout, assigns, do: content) do 6 | render(outer_layout, Map.put(assigns, :inner_layout, content)) 7 | end 8 | 9 | defp body_class(nil), do: "t-light" 10 | defp body_class(%{settings: %{theme: theme}}), do: "t-#{theme}" 11 | end 12 | -------------------------------------------------------------------------------- /lib/lucidboard_web/views/page_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.PageView do 2 | use LucidboardWeb, :view 3 | 4 | @changelog_html [File.cwd!(), "CHANGELOG.md"] 5 | |> Path.join() 6 | |> File.read!() 7 | |> Earmark.as_html!() 8 | 9 | def render("changelog.html", _params) do 10 | raw(""" 11 |
12 | #{@changelog_html} 13 |
14 | """) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/lucidboard_web/views/user_view.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.UserView do 2 | use LucidboardWeb, :view 3 | end 4 | -------------------------------------------------------------------------------- /lib/release_tasks.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.ReleaseTasks do 2 | @moduledoc "Tasks related to Distillery releases" 3 | def migrate do 4 | {:ok, _} = Application.ensure_all_started(:lucidboard) 5 | 6 | path = Application.app_dir(:lucidboard, "priv/repo/migrations") 7 | 8 | Ecto.Migrator.run(Lucidboard.Repo, path, :up, all: true) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.MixProject do 2 | @moduledoc false 3 | use Mix.Project 4 | 5 | def project do 6 | [ 7 | app: :lucidboard, 8 | version: "0.0.1", 9 | elixir: "~> 1.9", 10 | elixirc_paths: elixirc_paths(Mix.env()), 11 | compilers: [:phoenix, :gettext] ++ Mix.compilers(), 12 | start_permanent: Mix.env() == :prod, 13 | aliases: aliases(), 14 | releases: releases(), 15 | deps: deps(), 16 | test_coverage: [tool: ExCoveralls], 17 | preferred_cli_env: [ 18 | coveralls: :test, 19 | "coveralls.detail": :test, 20 | "coveralls.post": :test, 21 | "coveralls.html": :test 22 | ] 23 | ] 24 | end 25 | 26 | def application do 27 | [ 28 | mod: {Lucidboard.Application, []}, 29 | extra_applications: [ 30 | :logger, 31 | :runtime_tools, 32 | :scrivener_ecto, 33 | :ueberauth 34 | ] 35 | ] 36 | end 37 | 38 | defp elixirc_paths(:test), do: ["lib", "test/support"] 39 | defp elixirc_paths(_), do: ["lib"] 40 | 41 | defp deps do 42 | [ 43 | {:phoenix, "~> 1.4.0"}, 44 | {:phoenix_pubsub, "~> 1.1"}, 45 | {:ecto_sql, "~> 3.0"}, 46 | {:phoenix_ecto, "~> 4.0"}, 47 | {:postgrex, ">= 0.0.0"}, 48 | {:phoenix_html, "~> 2.11"}, 49 | {:phoenix_live_reload, "~> 1.2", only: :dev}, 50 | {:gettext, "~> 0.11"}, 51 | {:plug_cowboy, "~> 2.0"}, 52 | {:jason, "~> 1.0"}, 53 | {:focus, "~> 0.3.5"}, 54 | {:credo, "~> 1.0.0", only: [:dev, :test], runtime: false}, 55 | {:dialyxir, "~> 1.0.0-rc.4", only: [:dev], runtime: false}, 56 | {:excoveralls, "~> 0.10", only: :test}, 57 | {:mix_test_watch, "~> 0.8", only: :dev, runtime: false}, 58 | {:phoenix_live_view, "~> 0.1.0"}, 59 | {:ueberauth_github, "~> 0.7"}, 60 | {:ueberauth_pingfed, 61 | git: "https://github.com/borodark/ueberauth_pingfed.git"}, 62 | {:timex, "~> 3.1"}, 63 | {:scrivener_ecto, "~> 2.0"}, 64 | {:ecto_enum, "~> 1.2"}, 65 | {:earmark, "~> 1.3.5"} 66 | ] 67 | end 68 | 69 | defp aliases do 70 | [ 71 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"], 72 | "ecto.reset": ["ecto.drop", "ecto.setup"], 73 | test: ["ecto.create --quiet", "ecto.migrate", "test"], 74 | lint: "credo --strict" 75 | ] 76 | end 77 | 78 | defp releases do 79 | [ 80 | lucidboard: [ 81 | include_executables_for: [:unix], 82 | applications: [runtime_tools: :permanent] 83 | ] 84 | ] 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /priv/gettext/en/LC_MESSAGES/errors.po: -------------------------------------------------------------------------------- 1 | ## `msgid`s in this file come from POT (.pot) files. 2 | ## 3 | ## Do not add, change, or remove `msgid`s manually here as 4 | ## they're tied to the ones in the corresponding POT file 5 | ## (with the same domain). 6 | ## 7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge` 8 | ## to merge POT files into PO files. 9 | msgid "" 10 | msgstr "" 11 | "Language: en\n" 12 | 13 | ## From Ecto.Changeset.cast/4 14 | msgid "can't be blank" 15 | msgstr "" 16 | 17 | ## From Ecto.Changeset.unique_constraint/3 18 | msgid "has already been taken" 19 | msgstr "" 20 | 21 | ## From Ecto.Changeset.put_change/3 22 | msgid "is invalid" 23 | msgstr "" 24 | 25 | ## From Ecto.Changeset.validate_acceptance/3 26 | msgid "must be accepted" 27 | msgstr "" 28 | 29 | ## From Ecto.Changeset.validate_format/3 30 | msgid "has invalid format" 31 | msgstr "" 32 | 33 | ## From Ecto.Changeset.validate_subset/3 34 | msgid "has an invalid entry" 35 | msgstr "" 36 | 37 | ## From Ecto.Changeset.validate_exclusion/3 38 | msgid "is reserved" 39 | msgstr "" 40 | 41 | ## From Ecto.Changeset.validate_confirmation/3 42 | msgid "does not match confirmation" 43 | msgstr "" 44 | 45 | ## From Ecto.Changeset.no_assoc_constraint/3 46 | msgid "is still associated with this entry" 47 | msgstr "" 48 | 49 | msgid "are still associated with this entry" 50 | msgstr "" 51 | 52 | ## From Ecto.Changeset.validate_length/3 53 | msgid "should be %{count} character(s)" 54 | msgid_plural "should be %{count} character(s)" 55 | msgstr[0] "" 56 | msgstr[1] "" 57 | 58 | msgid "should have %{count} item(s)" 59 | msgid_plural "should have %{count} item(s)" 60 | msgstr[0] "" 61 | msgstr[1] "" 62 | 63 | msgid "should be at least %{count} character(s)" 64 | msgid_plural "should be at least %{count} character(s)" 65 | msgstr[0] "" 66 | msgstr[1] "" 67 | 68 | msgid "should have at least %{count} item(s)" 69 | msgid_plural "should have at least %{count} item(s)" 70 | msgstr[0] "" 71 | msgstr[1] "" 72 | 73 | msgid "should be at most %{count} character(s)" 74 | msgid_plural "should be at most %{count} character(s)" 75 | msgstr[0] "" 76 | msgstr[1] "" 77 | 78 | msgid "should have at most %{count} item(s)" 79 | msgid_plural "should have at most %{count} item(s)" 80 | msgstr[0] "" 81 | msgstr[1] "" 82 | 83 | ## From Ecto.Changeset.validate_number/3 84 | msgid "must be less than %{number}" 85 | msgstr "" 86 | 87 | msgid "must be greater than %{number}" 88 | msgstr "" 89 | 90 | msgid "must be less than or equal to %{number}" 91 | msgstr "" 92 | 93 | msgid "must be greater than or equal to %{number}" 94 | msgstr "" 95 | 96 | msgid "must be equal to %{number}" 97 | msgstr "" 98 | -------------------------------------------------------------------------------- /priv/gettext/errors.pot: -------------------------------------------------------------------------------- 1 | ## This is a PO Template file. 2 | ## 3 | ## `msgid`s here are often extracted from source code. 4 | ## Add new translations manually only if they're dynamic 5 | ## translations that can't be statically extracted. 6 | ## 7 | ## Run `mix gettext.extract` to bring this file up to 8 | ## date. Leave `msgstr`s empty as changing them here has no 9 | ## effect: edit them in PO (`.po`) files instead. 10 | 11 | ## From Ecto.Changeset.cast/4 12 | msgid "can't be blank" 13 | msgstr "" 14 | 15 | ## From Ecto.Changeset.unique_constraint/3 16 | msgid "has already been taken" 17 | msgstr "" 18 | 19 | ## From Ecto.Changeset.put_change/3 20 | msgid "is invalid" 21 | msgstr "" 22 | 23 | ## From Ecto.Changeset.validate_acceptance/3 24 | msgid "must be accepted" 25 | msgstr "" 26 | 27 | ## From Ecto.Changeset.validate_format/3 28 | msgid "has invalid format" 29 | msgstr "" 30 | 31 | ## From Ecto.Changeset.validate_subset/3 32 | msgid "has an invalid entry" 33 | msgstr "" 34 | 35 | ## From Ecto.Changeset.validate_exclusion/3 36 | msgid "is reserved" 37 | msgstr "" 38 | 39 | ## From Ecto.Changeset.validate_confirmation/3 40 | msgid "does not match confirmation" 41 | msgstr "" 42 | 43 | ## From Ecto.Changeset.no_assoc_constraint/3 44 | msgid "is still associated with this entry" 45 | msgstr "" 46 | 47 | msgid "are still associated with this entry" 48 | msgstr "" 49 | 50 | ## From Ecto.Changeset.validate_length/3 51 | msgid "should be %{count} character(s)" 52 | msgid_plural "should be %{count} character(s)" 53 | msgstr[0] "" 54 | msgstr[1] "" 55 | 56 | msgid "should have %{count} item(s)" 57 | msgid_plural "should have %{count} item(s)" 58 | msgstr[0] "" 59 | msgstr[1] "" 60 | 61 | msgid "should be at least %{count} character(s)" 62 | msgid_plural "should be at least %{count} character(s)" 63 | msgstr[0] "" 64 | msgstr[1] "" 65 | 66 | msgid "should have at least %{count} item(s)" 67 | msgid_plural "should have at least %{count} item(s)" 68 | msgstr[0] "" 69 | msgstr[1] "" 70 | 71 | msgid "should be at most %{count} character(s)" 72 | msgid_plural "should be at most %{count} character(s)" 73 | msgstr[0] "" 74 | msgstr[1] "" 75 | 76 | msgid "should have at most %{count} item(s)" 77 | msgid_plural "should have at most %{count} item(s)" 78 | msgstr[0] "" 79 | msgstr[1] "" 80 | 81 | ## From Ecto.Changeset.validate_number/3 82 | msgid "must be less than %{number}" 83 | msgstr "" 84 | 85 | msgid "must be greater than %{number}" 86 | msgstr "" 87 | 88 | msgid "must be less than or equal to %{number}" 89 | msgstr "" 90 | 91 | msgid "must be greater than or equal to %{number}" 92 | msgstr "" 93 | 94 | msgid "must be equal to %{number}" 95 | msgstr "" 96 | -------------------------------------------------------------------------------- /priv/repo/migrations/20180507203357_initial.exs: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Repo.Migrations.Initial do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | def change do 6 | create table(:users) do 7 | add(:name, :string, null: false) 8 | add(:full_name, :string, null: false) 9 | add(:settings, :jsonb, null: false, default: "{}") 10 | add(:avatar_url, :string) 11 | 12 | timestamps() 13 | end 14 | 15 | create(unique_index(:users, :name)) 16 | 17 | create table(:boards) do 18 | add(:title, :string, null: false, default: "") 19 | add(:settings, :jsonb, null: false, default: "{}") 20 | add(:user_id, references(:users), null: false) 21 | 22 | timestamps() 23 | end 24 | 25 | create table(:columns, primary_key: false) do 26 | add(:id, :uuid, primary_key: true) 27 | add(:title, :string, null: false) 28 | add(:pos, :integer, null: false) 29 | add(:board_id, references(:boards, on_delete: :delete_all), null: false) 30 | end 31 | 32 | create table(:piles, primary_key: false) do 33 | add(:id, :uuid, primary_key: true) 34 | add(:pos, :integer, null: false) 35 | 36 | add(:column_id, references(:columns, type: :uuid, on_delete: :delete_all), 37 | null: false 38 | ) 39 | end 40 | 41 | create table(:cards, primary_key: false) do 42 | add(:id, :uuid, primary_key: true) 43 | add(:pos, :integer, null: false) 44 | add(:body, :string, null: false) 45 | add(:settings, :jsonb, null: false, default: "{}") 46 | 47 | add(:pile_id, references(:piles, type: :uuid, on_delete: :delete_all), 48 | null: false 49 | ) 50 | 51 | add(:user_id, references(:users), null: false) 52 | end 53 | 54 | create table(:likes, primary_key: false) do 55 | add(:id, :uuid, primary_key: true) 56 | add(:user_id, references(:users), null: false) 57 | 58 | add(:card_id, references(:cards, type: :uuid, on_delete: :delete_all), 59 | null: false 60 | ) 61 | end 62 | 63 | create table(:board_roles, primary_key: false) do 64 | add(:id, :uuid, primary_key: true) 65 | add(:role, BoardRoleEnum.type(), null: false) 66 | add(:user_id, references(:users), null: false) 67 | 68 | add(:board_id, references(:boards, on_delete: :delete_all), null: false) 69 | end 70 | 71 | create table(:events) do 72 | add(:user_id, references(:users), null: false) 73 | add(:board_id, references(:boards), null: false) 74 | add(:desc, :string, null: false) 75 | 76 | timestamps() 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190903172452_private_boards.exs: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Repo.Migrations.PrivateBoards do 2 | use Ecto.Migration 3 | import Ecto.Adapters.SQL, only: [query!: 3] 4 | alias Lucidboard.Repo 5 | 6 | def up do 7 | # All existing boards have been and will remain open. 8 | query!( 9 | Repo, 10 | "UPDATE boards SET settings = jsonb_set(settings, '{access}', '\"open\"')", 11 | [] 12 | ) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190903190049_admin.exs: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Repo.Migrations.Admin do 2 | use Ecto.Migration 3 | 4 | def change do 5 | alter table("users") do 6 | add :admin, :boolean, default: false 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190906183517_anonymous_boards.exs: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Repo.Migrations.AnonymousBoards do 2 | use Ecto.Migration 3 | import Ecto.Adapters.SQL, only: [query!: 3] 4 | alias Lucidboard.Repo 5 | 6 | def up do 7 | query!( 8 | Repo, 9 | "UPDATE boards SET settings = jsonb_set(settings, '{anonymous}', 'true')", 10 | [] 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | alias Lucidboard.Seeds 2 | 3 | Seeds.insert!() 4 | -------------------------------------------------------------------------------- /rel/config.exs: -------------------------------------------------------------------------------- 1 | # Import all plugins from `rel/plugins` 2 | # They can then be used by adding `plugin MyPlugin` to 3 | # either an environment, or release definition, where 4 | # `MyPlugin` is the name of the plugin module. 5 | ~w(rel plugins *.exs) 6 | |> Path.join() 7 | |> Path.wildcard() 8 | |> Enum.map(&Code.eval_file(&1)) 9 | 10 | use Mix.Releases.Config, 11 | # This sets the default release built by `mix release` 12 | default_release: :default, 13 | # This sets the default environment used by `mix release` 14 | default_environment: Mix.env() 15 | 16 | # For a full list of config options for both releases 17 | # and environments, visit https://hexdocs.pm/distillery/config/distillery.html 18 | 19 | 20 | # You may define one or more environments in this file, 21 | # an environment's settings will override those of a release 22 | # when building in that environment, this combination of release 23 | # and environment configuration is called a profile 24 | 25 | environment :dev do 26 | # If you are running Phoenix, you should make sure that 27 | # server: true is set and the code reloader is disabled, 28 | # even in dev mode. 29 | # It is recommended that you build with MIX_ENV=prod and pass 30 | # the --env flag to Distillery explicitly if you want to use 31 | # dev mode. 32 | set dev_mode: true 33 | set include_erts: false 34 | set cookie: :"T`VQakjDre1p>bxy}(!gy%^O~{c?OO4MHSuxKDS^XLmWtB?>dm>VHca[tTf78q^zG5N1x8|)6J!?m,x.iJ4xx.*LG_n$DyI.Lj$8_]h^j" 42 | set vm_args: "rel/vm.args" 43 | end 44 | 45 | # You may define one or more releases in this file. 46 | # If you have not set a default release, or selected one 47 | # when running `mix release`, the first release in the file 48 | # will be used by default 49 | 50 | release :lucidboard do 51 | set version: current_version(:lucidboard) 52 | set applications: [:runtime_tools] 53 | 54 | set( 55 | config_providers: [ 56 | { 57 | Toml.Provider, 58 | path: "${RELEASE_ROOT_DIR}/config.toml", 59 | transforms: [ConfigTransformer] 60 | } 61 | ] 62 | ) 63 | 64 | set(overlays: [{:copy, "rel/config/config.toml", "config.toml"}]) 65 | end 66 | -------------------------------------------------------------------------------- /rel/config/config.toml: -------------------------------------------------------------------------------- 1 | [lucidboard] 2 | 3 | "LucidboardWeb.Endpoint".http.port = 8080 4 | "LucidboardWeb.Endpoint".url.port = 8080 5 | "LucidboardWeb.Endpoint".url.host = "${URL_HOST | default: localhost}" 6 | 7 | "Lucidboard.Repo".database = "${PG_DB | default: lucidboard_prod}" 8 | "Lucidboard.Repo".hostname = "${PG_HOST | default: db}" 9 | "Lucidboard.Repo".port = "${PG_PORT | default: 5432}" 10 | "Lucidboard.Repo".password = "${PG_PASS | default: verysecure123}" 11 | 12 | auth_provider = "(atom)${AUTH_PROVIDER | default: github}" 13 | 14 | [ueberauth."Ueberauth.Strategy.Github.OAuth"] 15 | client_id = "${GITHUB_CLIENT_ID}" 16 | client_secret = "${GITHUB_CLIENT_SECRET}" 17 | 18 | [ueberauth."Ueberauth.Strategy.PingFed.OAuth"] 19 | site = "${PINGFED_SITE}" 20 | redirect_uri = "${PINGFED_REDIRECT_URI}" 21 | client_id = "${PINGFED_CLIENT_ID}" 22 | client_secret = "${PINGFED_CLIENT_SECRET}" 23 | -------------------------------------------------------------------------------- /rel/hooks/post_start/migrate.sh: -------------------------------------------------------------------------------- 1 | set +e 2 | 3 | echo "Preparing to run migrations" 4 | 5 | while true; do 6 | nodetool ping 7 | EXIT_CODE=$? 8 | if [ $EXIT_CODE -eq 0 ]; then 9 | echo "Application is up!" 10 | break 11 | fi 12 | done 13 | 14 | set -e 15 | 16 | echo "Running migrations" 17 | bin/lucidboard rpc "Elixir.Lucidboard.ReleaseTasks.migrate" 18 | echo "Migrations run successfully" -------------------------------------------------------------------------------- /rel/plugins/.gitignore: -------------------------------------------------------------------------------- 1 | *.* 2 | !*.exs 3 | !.gitignore -------------------------------------------------------------------------------- /rel/vm.args: -------------------------------------------------------------------------------- 1 | ## This file provide the arguments provided to the VM at startup 2 | ## You can find a full list of flags and their behaviours at 3 | ## http://erlang.org/doc/man/erl.html 4 | 5 | ## Name of the node 6 | -name <%= release_name %>@127.0.0.1 7 | 8 | ## Cookie for distributed erlang 9 | -setcookie <%= release.profile.cookie %> 10 | 11 | ## Heartbeat management; auto-restarts VM if it dies or becomes unresponsive 12 | ## (Disabled by default..use with caution!) 13 | ##-heart 14 | 15 | ## Enable kernel poll and a few async threads 16 | ##+K true 17 | ##+A 5 18 | ## For OTP21+, the +A flag is not used anymore, 19 | ## +SDio replace it to use dirty schedulers 20 | ##+SDio 5 21 | 22 | ## Increase number of concurrent ports/sockets 23 | ##-env ERL_MAX_PORTS 4096 24 | 25 | ## Tweak GC to run more often 26 | ##-env ERL_FULLSWEEP_AFTER 10 27 | 28 | # Enable SMP automatically based on availability 29 | # On OTP21+, this is not needed anymore. 30 | -smp auto 31 | -------------------------------------------------------------------------------- /test/lucidboard/account_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.AccountTest do 2 | use ExUnit.Case 3 | import Lucidboard.Account 4 | alias Lucidboard.{Board, BoardRole, BoardSettings, User} 5 | 6 | test "has_role?: open boards are cool for non-owners" do 7 | assert has_role?( 8 | %User{}, 9 | %Board{settings: %BoardSettings{access: "open"}}, 10 | :observer 11 | ) 12 | 13 | assert has_role?( 14 | %User{}, 15 | %Board{settings: %BoardSettings{access: "open"}}, 16 | :contributor 17 | ) 18 | 19 | refute has_role?( 20 | %User{}, 21 | %Board{settings: %BoardSettings{access: "open"}, board_roles: []}, 22 | :owner 23 | ) 24 | end 25 | 26 | test "has_role?: public boards" do 27 | assert has_role?( 28 | %User{}, 29 | %Board{settings: %BoardSettings{access: "public"}}, 30 | :observer 31 | ) 32 | 33 | refute has_role?( 34 | %User{}, 35 | %Board{settings: %BoardSettings{access: "public"}, board_roles: []}, 36 | :contributor 37 | ) 38 | 39 | assert has_role?( 40 | %User{id: 2}, 41 | %Board{ 42 | settings: %BoardSettings{access: "public"}, 43 | board_roles: [%BoardRole{user_id: 2, role: :contributor}] 44 | }, 45 | :contributor 46 | ) 47 | 48 | refute has_role?( 49 | %User{id: 2}, 50 | %Board{ 51 | settings: %BoardSettings{access: "public"}, 52 | board_roles: [%BoardRole{user_id: 2, role: :contributor}] 53 | }, 54 | :owner 55 | ) 56 | end 57 | 58 | test "has_role?: private boards are restrictive" do 59 | refute has_role?( 60 | %User{}, 61 | %Board{ 62 | settings: %BoardSettings{access: "private"}, 63 | board_roles: [] 64 | }, 65 | :owner 66 | ) 67 | 68 | refute has_role?( 69 | %User{id: 3}, 70 | %Board{ 71 | settings: %BoardSettings{access: "private"}, 72 | board_roles: [%BoardRole{user_id: 3, role: :contributor}] 73 | }, 74 | :owner 75 | ) 76 | 77 | assert has_role?( 78 | %User{id: 3}, 79 | %Board{ 80 | settings: %BoardSettings{access: "private"}, 81 | board_roles: [%BoardRole{user_id: 3, role: :owner}] 82 | }, 83 | :owner 84 | ) 85 | end 86 | 87 | test "admin is always an owner" do 88 | assert has_role?( 89 | %User{admin: true}, 90 | %Board{settings: %BoardSettings{access: "private"}}, 91 | :owner 92 | ) 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/lucidboard/glass_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.GlassTest do 2 | use LucidboardWeb.ConnCase, async: true 3 | import Lucidboard.BoardFixtures 4 | alias Lucidboard.Twiddler.Glass 5 | 6 | test "get column lens by id" do 7 | board = board_fixture() 8 | {:ok, lens} = Glass.column_by_id(board, 2) 9 | assert "Col2" == Focus.view(lens, board).title 10 | assert :not_found == Glass.column_by_id(board, 99) 11 | end 12 | 13 | test "get card lens by id" do 14 | board = board_fixture() 15 | {:ok, lens} = Glass.card_by_id(board, 2) 16 | assert "whoa" == Focus.view(lens, board).body 17 | assert :not_found == Glass.card_by_id(board, 99) 18 | end 19 | 20 | test "get pile lens by id" do 21 | board = board_fixture() 22 | {:ok, lens} = Glass.pile_by_id(board, 2) 23 | assert 1 == length(Focus.view(lens, board).cards) 24 | assert :not_found == Glass.card_by_id(board, 99) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/lucidboard/live_board_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.LiveBoardTest do 2 | @moduledoc false 3 | use LucidboardWeb.ConnCase, async: false 4 | alias Lucidboard.{Board, Column, LiveBoard, Seeds, Twiddler} 5 | 6 | test "basic LiveBoard lifecycle" do 7 | user = Seeds.get_user() 8 | 9 | # Create a board record in the db 10 | {:ok, %Board{id: board_id, columns: [%Column{id: column_id}]}} = 11 | [ 12 | user: user, 13 | title: "Awesome", 14 | columns: [Column.new(title: "foo", pos: 0)] 15 | ] 16 | |> Board.new() 17 | |> Twiddler.insert() 18 | 19 | # Start a liveboard based on it 20 | {:ok, pid} = LiveBoard.start(board_id) 21 | assert is_pid(pid) 22 | 23 | # Set the column title 24 | action = {:update_column, id: column_id, title: "the new title"} 25 | LiveBoard.call(board_id, {:action, action, user: user}) 26 | 27 | # Get the board state from the liveboard 28 | {:ok, 29 | %{ 30 | board: %Board{columns: [%Column{title: from_live_board}]}, 31 | events: _events 32 | }} = LiveBoard.call(board_id, :state) 33 | 34 | # Ensure it's the new title 35 | assert "the new title" == from_live_board 36 | 37 | # Give the scribe long enough to save and fetch it from the database 38 | :timer.sleep(50) 39 | %Board{columns: [%Column{title: from_db}]} = Twiddler.by_id(board_id) 40 | 41 | # Ensure the new title has persisted 42 | assert "the new title" == from_db 43 | 44 | :ok = LiveBoard.stop(board_id) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/lucidboard/twiddler/op_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.Twiddler.OpTest do 2 | use ExUnit.Case 3 | import Lucidboard.BoardFixtures 4 | alias Lucidboard.Twiddler.Op 5 | 6 | doctest Lucidboard.Twiddler.Op 7 | 8 | # Note that this test does not check the tx_fn. TwiddlerTest will cover that. 9 | test "move_items" do 10 | bodies = fn cards -> Enum.map(cards, & &1.body) end 11 | cards = cards_fixture() 12 | 13 | # Baseline 14 | assert ~w(fred wilma pebbles) == bodies.(cards) 15 | 16 | {:ok, t1_card, t1_cards} = Op.move_item(cards, 0, 2) 17 | assert "fred" == t1_card.body 18 | assert ~w(wilma pebbles fred) == bodies.(t1_cards) 19 | 20 | # 2 is the max position since length(cards) == 3 21 | assert {:error, _} = Op.move_item(cards, 1, 3) 22 | 23 | assert {:error, _} = Op.move_item(cards, 9, 0) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/lucidboard_web/controllers/page_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.PageControllerTest do 2 | @moduledoc false 3 | use LucidboardWeb.ConnCase 4 | 5 | test "GET /", %{conn: conn} do 6 | conn = get(conn, "/") 7 | assert html_response(conn, 200) =~ "Lucidboard" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/lucidboard_web/view_helper_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.ViewHelperTest do 2 | use ExUnit.Case 3 | alias Lucidboard.BoardRole 4 | alias LucidboardWeb.ViewHelper 5 | 6 | test "more_than_one_owner?" do 7 | assert false == ViewHelper.more_than_one_owner?([]) 8 | 9 | assert true == 10 | ViewHelper.more_than_one_owner?([ 11 | %BoardRole{board_id: 1, user_id: 1, role: :observer}, 12 | %BoardRole{board_id: 1, user_id: 2, role: :owner}, 13 | %BoardRole{board_id: 1, user_id: 3, role: :owner} 14 | ]) 15 | 16 | assert false == 17 | ViewHelper.more_than_one_owner?([ 18 | %BoardRole{board_id: 1, user_id: 1, role: :observer}, 19 | %BoardRole{board_id: 1, user_id: 2, role: :owner} 20 | ]) 21 | end 22 | 23 | # @spec more_than_one_owner([BoardRole.t()]) :: boolean 24 | # def more_than_one_owner(roles) do 25 | # true == 26 | # Enum.reduce_while(roles, 0, fn 27 | # %{type: :owner}, 1 -> {:halt, true} 28 | # %{type: :owner}, acc -> {:cont, acc + 1} 29 | # _, acc -> {:cont, acc} 30 | # end) 31 | # end 32 | end 33 | -------------------------------------------------------------------------------- /test/lucidboard_web/views/error_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.ErrorViewTest do 2 | use LucidboardWeb.ConnCase, async: true 3 | 4 | # Bring render/3 and render_to_string/3 for testing custom views 5 | import Phoenix.View 6 | 7 | test "renders 404.html" do 8 | assert render_to_string(LucidboardWeb.ErrorView, "404.html", []) == 9 | "Not Found" 10 | end 11 | 12 | test "renders 500.html" do 13 | assert render_to_string(LucidboardWeb.ErrorView, "500.html", []) == 14 | "Internal Server Error" 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/lucidboard_web/views/layout_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.LayoutViewTest do 2 | use LucidboardWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/lucidboard_web/views/page_view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.PageViewTest do 2 | use LucidboardWeb.ConnCase, async: true 3 | end 4 | -------------------------------------------------------------------------------- /test/support/board_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.BoardCase do 2 | @moduledoc """ 3 | Inserts a board fixture record to the database in setup. The context 4 | includes the inserted board under the `:board` key. 5 | """ 6 | use ExUnit.CaseTemplate 7 | alias Ecto.Adapters.SQL.Sandbox 8 | alias Ecto.UUID 9 | alias Lucidboard.{Repo, Seeds, Twiddler, User} 10 | 11 | using do 12 | quote do 13 | use ExUnit.Case 14 | end 15 | end 16 | 17 | setup tags do 18 | :ok = Sandbox.checkout(Repo) 19 | 20 | unless tags[:async] do 21 | Sandbox.mode(Repo, {:shared, self()}) 22 | end 23 | 24 | user = 25 | Repo.insert!(User.new(name: "jeff-#{UUID.generate()}", full_name: "Jeff")) 26 | 27 | %{id: board_id} = Repo.insert!(Seeds.board(user)) 28 | board = Twiddler.by_id(board_id) 29 | 30 | {:ok, user: user, board: board} 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/support/board_fixtures.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.BoardFixtures do 2 | @moduledoc "Some board data for unit tests" 3 | alias Lucidboard.{Board, Card, Column, Pile} 4 | 5 | def cards_fixture do 6 | [ 7 | %Card{body: "fred", id: 1, pile_id: 1, pos: 0}, 8 | %Card{body: "wilma", id: 2, pile_id: 1, pos: 1}, 9 | %Card{body: "pebbles", id: 3, pile_id: 1, pos: 2} 10 | ] 11 | end 12 | 13 | def board_fixture do 14 | %Board{ 15 | columns: [ 16 | %Column{board_id: 1, id: 1, piles: [], pos: 0, title: "Col1"}, 17 | %Column{ 18 | board_id: 1, 19 | id: 2, 20 | piles: [ 21 | %Pile{ 22 | cards: [%Card{body: "hi", id: 1, pile_id: 1, pos: 0}], 23 | column_id: 2, 24 | id: 1, 25 | pos: 0 26 | } 27 | ], 28 | pos: 1, 29 | title: "Col2" 30 | }, 31 | %Column{ 32 | board_id: 1, 33 | id: 3, 34 | piles: [ 35 | %Pile{ 36 | cards: [ 37 | %Card{body: "whoa", id: 2, pile_id: 3, pos: 0}, 38 | %Card{body: "srs?", id: 3, pile_id: 3, pos: 1}, 39 | %Card{body: "neat", id: 4, pile_id: 3, pos: 2} 40 | ], 41 | column_id: 3, 42 | id: 3, 43 | pos: 0 44 | }, 45 | %Pile{ 46 | cards: [%Card{body: "definitely", id: 5, pile_id: 2, pos: 0}], 47 | column_id: 3, 48 | id: 2, 49 | pos: 1 50 | } 51 | ], 52 | pos: 2, 53 | title: "Col3" 54 | } 55 | ], 56 | id: 1, 57 | inserted_at: ~N[2018-11-27 03:49:28], 58 | title: "My Test Board", 59 | updated_at: ~N[2018-11-27 03:49:28] 60 | } 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | alias Ecto.Adapters.SQL.Sandbox 18 | 19 | using do 20 | quote do 21 | # Import conveniences for testing with channels 22 | use Phoenix.ChannelTest 23 | 24 | # The default endpoint for testing 25 | @endpoint LucidboardWeb.Endpoint 26 | end 27 | end 28 | 29 | setup tags do 30 | :ok = Sandbox.checkout(Lucidboard.Repo) 31 | 32 | unless tags[:async] do 33 | Sandbox.mode(Lucidboard.Repo, {:shared, self()}) 34 | end 35 | 36 | :ok 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule LucidboardWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common datastructures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | it cannot be async. For this reason, every test runs 12 | inside a transaction which is reset at the beginning 13 | of the test unless the test case is marked as async. 14 | """ 15 | 16 | use ExUnit.CaseTemplate 17 | alias Ecto.Adapters.SQL.Sandbox 18 | alias Phoenix.ConnTest 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with connections 23 | use Phoenix.ConnTest 24 | import LucidboardWeb.Router.Helpers 25 | 26 | # The default endpoint for testing 27 | @endpoint LucidboardWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | :ok = Sandbox.checkout(Lucidboard.Repo) 33 | 34 | unless tags[:async] do 35 | Sandbox.mode(Lucidboard.Repo, {:shared, self()}) 36 | end 37 | 38 | {:ok, conn: ConnTest.build_conn()} 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Lucidboard.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | alias Ecto.Adapters.SQL.Sandbox 17 | 18 | using do 19 | quote do 20 | alias Lucidboard.Repo 21 | 22 | import Ecto 23 | import Ecto.Changeset 24 | import Ecto.Query 25 | import Lucidboard.DataCase 26 | end 27 | end 28 | 29 | setup tags do 30 | :ok = Sandbox.checkout(Lucidboard.Repo) 31 | 32 | unless tags[:async] do 33 | Sandbox.mode(Lucidboard.Repo, {:shared, self()}) 34 | end 35 | 36 | :ok 37 | end 38 | 39 | @doc """ 40 | A helper that transform changeset errors to a map of messages. 41 | 42 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 43 | assert "password is too short" in errors_on(changeset).password 44 | assert %{password: ["password is too short"]} = errors_on(changeset) 45 | 46 | """ 47 | def errors_on(changeset) do 48 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 49 | Enum.reduce(opts, message, fn {key, value}, acc -> 50 | String.replace(acc, "%{#{key}}", to_string(value)) 51 | end) 52 | end) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------