<%= get_flash(@conn, :info) %>
5 |<%= get_flash(@conn, :error) %>
6 |├── .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) [](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 |
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 |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 |75 | If you delete the card, it's gone forever. Are you sure? 76 |
77 | 81 |7 | <%= textarea f, :body, class: "lb-modal-card-textarea" %> 8 | <%= error_tag f, :body %> 9 | 12 |
13 |17 | Choose label color 18 |
19 | <%= radio_button f, :color, "2ecc71", checked: f.data.settings.color == "2ecc71", class: "lb-modal-radio-button" %> 20 | 25 | <%= radio_button f, :color, "3498db", checked: f.data.settings.color == "3498db", class: "lb-modal-radio-button" %> 26 | 31 | <%= radio_button f, :color, "e74c3c", checked: f.data.settings.color == "e74c3c", class: "lb-modal-radio-button" %> 32 | 37 | <%= radio_button f, :color, "e67e22", checked: f.data.settings.color == "e67e22", class: "lb-modal-radio-button" %> 38 | 43 |Likes Per User: <%= settings.likes_per_user %>
7 |Likes Per User Per Card: <%= settings.likes_per_user_per_card %>
8 |18 | 19 |
(This board is <%= settings.access %>.)
20 |64 | Showing page <%= @board_pagination.page_number %> of <%= @board_pagination.total_pages %> 65 |
66 |67 | Total boards: <%= @board_pagination.total_entries %> 68 |
69 |<%= get_flash(@conn, :info) %>
3 |<%= get_flash(@conn, :error) %>
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 |<%= get_flash(@conn, :info) %>
5 |<%= get_flash(@conn, :error) %>
6 |17 | <%= login_button() %> 18 |
19 | <% end %> 20 | 21 |Lucidboard is a tool for teams or individuals looking to:
24 | 25 |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 |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 |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 |