Merge branch 'main' into mobile-app/scoped-stylesheets
* main: (82 commits) We can remove ad-hoc handling now that we rely on page refreshes in the card perma Only broadcast when the preview changes, use the _later variant for the broadcast, add tests Update pinned cards when the title changes Rename to be more consistent Beautify board watchers list (#1946) Add missing data-attr on public board Fix hotkey text size on tiny viewports Use buil-in support for :self in Stimulus Save a bunch of invocations on morph events from children elements Fix: memoization was showing stale values when morphing GitHub actions: limit GITHUB_TOKEN permissions (#1933) Validate Identity email address Drop defunct user creation script Golden cards should be placed at the top Animate the column height with a stimulus controller so that it does not depend to the server to update when you D&D mise: respect .ruby-version Support custom validation messages Move css bit to the new blank slates CSS Prevent board names with only spaces and show validation message Keep the column color when D&D cards ...
This commit is contained in:
@@ -3,6 +3,9 @@ name: Checks
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
security:
|
||||
name: Security
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
|
||||
@@ -3,6 +3,9 @@ name: CI (SaaS)
|
||||
on:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test_oss:
|
||||
name: Test (OSS)
|
||||
|
||||
@@ -10,6 +10,9 @@ on:
|
||||
GH_TOKEN:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Tests (${{ matrix.mode }})
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
[settings]
|
||||
idiomatic_version_file_enable_tools = ["ruby"]
|
||||
|
||||
[env]
|
||||
PROMETHEUS_EXPORTER_URL = "http://127.0.0.1:9306/metrics"
|
||||
|
||||
@@ -15,7 +15,7 @@ bin/dev # Start development server (runs on port 3006)
|
||||
```
|
||||
|
||||
Development URL: http://fizzy.localhost:3006
|
||||
Login with: david@37signals.com (development fixtures), password will appear in the browser console
|
||||
Login with: david@example.com (development fixtures), password will appear in the browser console
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
@@ -142,7 +142,7 @@ Key recurring tasks (via `config/recurring.yml`):
|
||||
### Chrome MCP (Local Dev)
|
||||
|
||||
URL: `http://fizzy.localhost:3006`
|
||||
Login: david@37signals.com (passwordless magic link auth - check rails console for link)
|
||||
Login: david@example.com (passwordless magic link auth - check rails console for link)
|
||||
|
||||
Use Chrome MCP tools to interact with the running dev app for UI testing and debugging.
|
||||
|
||||
|
||||
+4
-4
@@ -6,7 +6,7 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: b22cb0c0cb39b103d816a66d560991acf0a57163
|
||||
revision: 3c3f6c8a253c8a0f346695374f927c9ab32fbefb
|
||||
branch: main
|
||||
specs:
|
||||
actioncable (8.2.0.alpha)
|
||||
@@ -216,7 +216,7 @@ GEM
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
jmespath (1.6.2)
|
||||
json (2.16.0)
|
||||
json (2.17.1)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.9.0)
|
||||
@@ -351,7 +351,7 @@ GEM
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (6.16.0)
|
||||
rdoc (6.16.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -443,7 +443,7 @@ GEM
|
||||
ostruct
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.8)
|
||||
stringio (3.1.9)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
|
||||
+5
-3
@@ -3,15 +3,17 @@ eval_gemfile "Gemfile"
|
||||
|
||||
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
|
||||
|
||||
|
||||
# SaaS-only functionality
|
||||
gem "activeresource", require: "active_resource"
|
||||
gem "queenbee", bc: "queenbee-plugin"
|
||||
gem "fizzy-saas", bc: "fizzy-saas"
|
||||
gem "console1984", bc: "console1984"
|
||||
gem "audits1984", bc: "audits1984"
|
||||
|
||||
# Telemetry
|
||||
gem "rails_structured_logging", bc: "rails-structured-logging"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
|
||||
# Telemetry
|
||||
gem "yabeda"
|
||||
gem "yabeda-actioncable"
|
||||
gem "yabeda-activejob", github: "basecamp/yabeda-activejob", branch: "bulk-and-scheduled-jobs"
|
||||
|
||||
+34
-8
@@ -1,9 +1,32 @@
|
||||
GIT
|
||||
remote: https://github.com/basecamp/audits1984
|
||||
revision: c790eaec0716c8df56b3d0ab5bf5d75e3e1b0ed0
|
||||
specs:
|
||||
audits1984 (0.1.7)
|
||||
console1984
|
||||
importmap-rails (>= 1.2.1)
|
||||
rinku
|
||||
rouge
|
||||
turbo-rails
|
||||
|
||||
GIT
|
||||
remote: https://github.com/basecamp/console1984
|
||||
revision: 02b1b9ee7fd7050174b6a98c2b43057553621dc4
|
||||
specs:
|
||||
console1984 (0.2.2)
|
||||
irb (~> 1.13)
|
||||
parser
|
||||
rails (>= 7.0)
|
||||
rainbow
|
||||
|
||||
GIT
|
||||
remote: https://github.com/basecamp/fizzy-saas
|
||||
revision: 6dd8bb616632edfabc3d814f88f0658d96486e17
|
||||
revision: 97d126e0a6084905aceaadf4facaa2a48b44e124
|
||||
specs:
|
||||
fizzy-saas (0.1.0)
|
||||
prometheus-client-mmap
|
||||
audits1984
|
||||
console1984
|
||||
prometheus-client-mmap (~> 1.4.0)
|
||||
queenbee
|
||||
rails (>= 8.1.0.beta1)
|
||||
rails_structured_logging
|
||||
@@ -367,32 +390,32 @@ GEM
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.6.0)
|
||||
prometheus-client-mmap (1.3.0)
|
||||
prometheus-client-mmap (1.4.0)
|
||||
base64
|
||||
bigdecimal
|
||||
logger
|
||||
rb_sys (~> 0.9.117)
|
||||
prometheus-client-mmap (1.3.0-aarch64-linux-gnu)
|
||||
prometheus-client-mmap (1.4.0-aarch64-linux-gnu)
|
||||
base64
|
||||
bigdecimal
|
||||
logger
|
||||
rb_sys (~> 0.9.117)
|
||||
prometheus-client-mmap (1.3.0-aarch64-linux-musl)
|
||||
prometheus-client-mmap (1.4.0-aarch64-linux-musl)
|
||||
base64
|
||||
bigdecimal
|
||||
logger
|
||||
rb_sys (~> 0.9.117)
|
||||
prometheus-client-mmap (1.3.0-arm64-darwin)
|
||||
prometheus-client-mmap (1.4.0-arm64-darwin)
|
||||
base64
|
||||
bigdecimal
|
||||
logger
|
||||
rb_sys (~> 0.9.117)
|
||||
prometheus-client-mmap (1.3.0-x86_64-linux-gnu)
|
||||
prometheus-client-mmap (1.4.0-x86_64-linux-gnu)
|
||||
base64
|
||||
bigdecimal
|
||||
logger
|
||||
rb_sys (~> 0.9.117)
|
||||
prometheus-client-mmap (1.3.0-x86_64-linux-musl)
|
||||
prometheus-client-mmap (1.4.0-x86_64-linux-musl)
|
||||
base64
|
||||
bigdecimal
|
||||
logger
|
||||
@@ -440,6 +463,7 @@ GEM
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rinku (2.0.6)
|
||||
rouge (4.6.1)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
@@ -610,6 +634,7 @@ PLATFORMS
|
||||
|
||||
DEPENDENCIES
|
||||
activeresource
|
||||
audits1984!
|
||||
autotuner
|
||||
aws-sdk-s3
|
||||
bcrypt (~> 3.1.7)
|
||||
@@ -618,6 +643,7 @@ DEPENDENCIES
|
||||
brakeman
|
||||
bundler-audit
|
||||
capybara
|
||||
console1984!
|
||||
debug
|
||||
faker
|
||||
fizzy-saas!
|
||||
|
||||
@@ -21,7 +21,7 @@ bin/dev
|
||||
|
||||
You'll be able to access the app in development at http://fizzy.localhost:3006.
|
||||
|
||||
To login, enter `david@37signals.com` and grab the verification code from the browser console to sign in.
|
||||
To login, enter `david@example.com` and grab the verification code from the browser console to sign in.
|
||||
|
||||
### Running tests
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@layer components {
|
||||
.blank-slate--drag {
|
||||
box-shadow: none !important;
|
||||
color: color-mix(in srgb, var(--card-color) 70%, var(--color-canvas));
|
||||
|
||||
p {
|
||||
font-size: var(--text-small);
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cards--considering & {
|
||||
background-color: var(--card-bg-color) !important;
|
||||
}
|
||||
|
||||
.cards--grid & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.blank-slate--empty {
|
||||
border: 2px dashed var(--color-ink-light);
|
||||
border-radius: 1ch;
|
||||
color: var(--color-ink-dark);
|
||||
margin-block-start: 5dvh;
|
||||
padding: 1ch 2ch;
|
||||
rotate: -3deg;
|
||||
font-weight: 500;
|
||||
|
||||
.cards:not(.cards--grid) & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.blank-slate--filters {
|
||||
.cards--grid:has(.card) & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cards__list:has(> :not(.blank-slate)) .card--hide-unless-empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -402,30 +402,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.blank-slate--card {
|
||||
box-shadow: none !important;
|
||||
color: color-mix(in srgb, var(--card-color) 70%, var(--color-canvas));
|
||||
|
||||
.cards--considering & {
|
||||
background-color: var(--card-bg-color) !important;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--text-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.blank-slate-for-grid {
|
||||
display: none;
|
||||
|
||||
.cards--grid & {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* TODO: I think this is legacy now? */
|
||||
.cards__heading {
|
||||
display: flex;
|
||||
|
||||
@@ -51,12 +51,14 @@
|
||||
padding-inline-end: var(--reaction-size);
|
||||
text-align: start;
|
||||
|
||||
:first-child {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
.action-text-content {
|
||||
> action-text-attachment:first-child figure {
|
||||
margin-block-start: 0.5ch;
|
||||
}
|
||||
|
||||
:last-child {
|
||||
margin-block-end: 0;
|
||||
> :last-child {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
--input-background: var(--color-canvas);
|
||||
}
|
||||
|
||||
&:has(.filter-toggle:hover) {
|
||||
&:has([data-controller~="tooltip"]:hover) {
|
||||
z-index: calc(var(--z-nav) + 1);
|
||||
}
|
||||
}
|
||||
@@ -150,9 +150,6 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 80ch) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.filters:not(.filters--expanded) {
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
aspect-ratio: 5/3;
|
||||
background-color: var(--color-ink-lightest);
|
||||
border-radius: 0.5em;
|
||||
container-type: inline-size;
|
||||
flex-basis: calc((100% - var(--gap) * 2) / 3);
|
||||
flex-direction: column;
|
||||
font-size: var(--text-small);
|
||||
@@ -212,6 +213,7 @@
|
||||
|
||||
@media (max-width: 639px) {
|
||||
font-size: var(--text-x-small);
|
||||
font-size: clamp(var(--text-xx-small), 3.15cqi, var(--text-small));
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|
||||
inset: 0 auto auto 50%;
|
||||
max-block-size: 70dvh;
|
||||
max-inline-size: min(55ch, calc(100vw - (var(--panel-padding) * 2)));
|
||||
min-inline-size: min(25ch, calc(100vw - (var(--panel-padding) * 2)));
|
||||
max-inline-size: min(55ch, calc(100vw - (var(--panel-padding))));
|
||||
min-inline-size: min(25ch, calc(100vw - (var(--panel-padding))));
|
||||
overflow: auto;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
/* Links should hug media contained within */
|
||||
a:has(img),
|
||||
a:has(video) {
|
||||
display: inline-block;
|
||||
inline-size: fit-content;
|
||||
}
|
||||
|
||||
/* Avoid extra space due to empty paragraphs */
|
||||
@@ -263,7 +263,7 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-block-start: var(--block-space-half);
|
||||
margin-block-start: 0.5ch;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ class Boards::Columns::NotNowsController < ApplicationController
|
||||
include BoardScoped
|
||||
|
||||
def show
|
||||
set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first.preloaded
|
||||
set_page_and_extract_portion_from @board.cards.postponed.latest.preloaded
|
||||
fresh_when etag: @page.records
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,7 @@ class BoardsController < ApplicationController
|
||||
include FilterScoped
|
||||
|
||||
before_action :set_board, except: %i[ new create ]
|
||||
before_action :ensure_permission_to_admin_board, only: %i[ update ]
|
||||
before_action :ensure_permission_to_admin_board, only: %i[ update destroy ]
|
||||
|
||||
def show
|
||||
if @filter.used?(ignore_boards: true)
|
||||
|
||||
@@ -2,6 +2,8 @@ class Cards::Comments::ReactionsController < ApplicationController
|
||||
include CardScoped
|
||||
|
||||
before_action :set_comment
|
||||
before_action :set_reaction, only: %i[ destroy ]
|
||||
before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ]
|
||||
|
||||
def index
|
||||
end
|
||||
@@ -14,7 +16,6 @@ class Cards::Comments::ReactionsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@reaction = @comment.reactions.find(params[:id])
|
||||
@reaction.destroy
|
||||
end
|
||||
|
||||
@@ -22,4 +23,12 @@ class Cards::Comments::ReactionsController < ApplicationController
|
||||
def set_comment
|
||||
@comment = @card.comments.find(params[:comment_id])
|
||||
end
|
||||
|
||||
def set_reaction
|
||||
@reaction = @comment.reactions.find(params[:id])
|
||||
end
|
||||
|
||||
def ensure_permision_to_administer_reaction
|
||||
head :forbidden if Current.user != @reaction.reacter
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,6 +7,6 @@ module ColumnScoped
|
||||
|
||||
private
|
||||
def set_column
|
||||
@column = Current.account.columns.find(params[:column_id])
|
||||
@column = Current.user.accessible_columns.find(params[:column_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class JoinCodesController < ApplicationController
|
||||
def create
|
||||
identity = Identity.find_or_create_by!(email_address: params.expect(:email_address))
|
||||
|
||||
@join_code.redeem { |account| identity.join(account) } unless identity.member_of?(@join_code.account)
|
||||
@join_code.redeem_if { |account| identity.join(account) }
|
||||
user = User.active.find_by!(account: @join_code.account, identity: identity)
|
||||
|
||||
if identity == Current.identity && user.setup?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Public::Boards::Columns::NotNowsController < Public::BaseController
|
||||
def show
|
||||
set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first
|
||||
set_page_and_extract_portion_from @board.cards.postponed.latest
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Sessions::MagicLinksController < ApplicationController
|
||||
disallow_account_scope
|
||||
require_unauthenticated_access
|
||||
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Try again in 15 minutes." }
|
||||
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" }
|
||||
|
||||
layout "public"
|
||||
|
||||
@@ -13,7 +13,7 @@ class Sessions::MagicLinksController < ApplicationController
|
||||
start_new_session_for magic_link.identity
|
||||
redirect_to after_sign_in_url(magic_link)
|
||||
else
|
||||
redirect_to session_magic_link_path, alert: "Try another code."
|
||||
redirect_to session_magic_link_path, flash: { shake: true }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -9,11 +9,16 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
if identity = Identity.find_by_email_address(email_address)
|
||||
magic_link = identity.send_magic_link
|
||||
serve_development_magic_link(magic_link)
|
||||
identity = Identity.find_by_email_address(email_address)
|
||||
|
||||
magic_link = if identity
|
||||
identity.send_magic_link
|
||||
else
|
||||
Signup.new(email_address: email_address).create_identity
|
||||
end
|
||||
|
||||
serve_development_magic_link(magic_link)
|
||||
|
||||
redirect_to session_magic_link_path
|
||||
end
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class Users::AvatarsController < ApplicationController
|
||||
if @user.system?
|
||||
redirect_to view_context.image_path("system_user.png")
|
||||
elsif @user.avatar.attached?
|
||||
redirect_to rails_blob_url(@user.avatar.variant(:thumb), disposition: "inline")
|
||||
redirect_to rails_blob_url(@user.avatar_thumbnail, disposition: "inline")
|
||||
elsif stale? @user, cache_control: cache_control
|
||||
render_initials
|
||||
end
|
||||
|
||||
@@ -8,12 +8,14 @@ class Users::EmailAddresses::ConfirmationsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
user = @user.change_email_address_using_token(token)
|
||||
if @user.change_email_address_using_token(token)
|
||||
terminate_session if Current.session
|
||||
start_new_session_for @user.identity
|
||||
|
||||
terminate_session if Current.session
|
||||
start_new_session_for user.identity
|
||||
|
||||
redirect_to edit_user_url(script_name: user.account.slug, id: user)
|
||||
redirect_to edit_user_url(script_name: @user.account.slug, id: @user)
|
||||
else
|
||||
render :invalid_token, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
module AccessesHelper
|
||||
MAX_DISPLAYED_WATCHERS = 8
|
||||
|
||||
def access_menu_tag(board, **options, &)
|
||||
tag.menu class: [ options[:class], { "toggler--toggled": board.all_access? } ], data: {
|
||||
controller: "filter toggle-class navigable-list",
|
||||
@@ -27,45 +25,28 @@ module AccessesHelper
|
||||
end
|
||||
|
||||
def board_watchers_list(board)
|
||||
watchers = board.watchers.with_avatars
|
||||
watchers = board.watchers.with_avatars.load
|
||||
|
||||
displayed_watchers = watchers.limit(MAX_DISPLAYED_WATCHERS)
|
||||
overflow_count = watchers.count - MAX_DISPLAYED_WATCHERS
|
||||
displayed_watchers = watchers.first(8)
|
||||
overflow_count = watchers.size - 8
|
||||
|
||||
safe_join([
|
||||
tag.strong(displayed_watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase"),
|
||||
tag.div(class: "board-tools__watching") do
|
||||
safe_join([
|
||||
safe_join(displayed_watchers.map { |watcher| avatar_tag(watcher) }),
|
||||
(tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do
|
||||
concat tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" })
|
||||
concat(tag.dialog(class: "board-tools__watching-dialog dialog panel", data: { dialog_target: "dialog" }, aria: { hidden: "true" }) do
|
||||
safe_join(watchers.map { |watcher| avatar_tag(watcher) })
|
||||
end)
|
||||
end if overflow_count > 0)
|
||||
].compact)
|
||||
end
|
||||
])
|
||||
tag.strong(watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase") +
|
||||
tag.div(avatar_tags(displayed_watchers), class: "board-tools__watching") do
|
||||
tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do
|
||||
tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" }) +
|
||||
tag.dialog(avatar_tags(watchers), class: "board-tools__watching-dialog dialog panel", data: { dialog_target: "dialog" }, aria: { hidden: "true" })
|
||||
end if overflow_count > 0
|
||||
end
|
||||
end
|
||||
|
||||
def involvement_button(board, access, show_watchers, icon_only)
|
||||
involvement_label_id = dom_id(board, :involvement_label)
|
||||
|
||||
button_to(
|
||||
board_involvement_path(board),
|
||||
method: :put,
|
||||
aria: { labelledby: involvement_label_id },
|
||||
class: class_names("btn", { "btn--reversed": access.watching? && icon_only }),
|
||||
params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only }
|
||||
) do
|
||||
safe_join([
|
||||
icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}"),
|
||||
tag.span(
|
||||
involvement_access_label(access),
|
||||
class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only),
|
||||
id: involvement_label_id
|
||||
)
|
||||
])
|
||||
board_involvement_path(board), method: :put,
|
||||
params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only },
|
||||
aria: { labelledby: dom_id(board, :involvement_label) }, title: access.access_only? ? "Watch this" : "Stop watching",
|
||||
class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do
|
||||
icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") +
|
||||
tag.span(class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -74,12 +55,4 @@ module AccessesHelper
|
||||
order = %w[ watching access_only ]
|
||||
order[(order.index(involvement.to_s) + 1) % order.size]
|
||||
end
|
||||
|
||||
def involvement_access_label(access)
|
||||
if access.access_only?
|
||||
"Watch this"
|
||||
else
|
||||
"Stop watching"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,10 @@ module AvatarsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def avatar_tags(users, **options)
|
||||
users.collect { avatar_tag(it, **options) }.join.html_safe
|
||||
end
|
||||
|
||||
def mail_avatar_tag(user, size: 48, **options)
|
||||
if user.avatar.attached?
|
||||
image_tag user_avatar_url(user), alt: user.name, class: "avatar", size: size, **options
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
module CardsHelper
|
||||
def card_article_tag(card, id: dom_id(card, :article), **options, &block)
|
||||
def card_article_tag(card, id: dom_id(card, :article), data: {}, **options, &block)
|
||||
classes = [
|
||||
options.delete(:class),
|
||||
("golden-effect" if card.golden?),
|
||||
@@ -7,10 +7,13 @@ module CardsHelper
|
||||
("card--active" if card.active?)
|
||||
].compact.join(" ")
|
||||
|
||||
data[:drag_and_drop_top] = true if card.golden? && !card.closed? && !card.postponed?
|
||||
|
||||
tag.article \
|
||||
id: id,
|
||||
style: "--card-color: #{card.color}; view-transition-name: #{id}",
|
||||
class: classes,
|
||||
data: data,
|
||||
**options,
|
||||
&block
|
||||
end
|
||||
|
||||
@@ -10,14 +10,16 @@ module ColumnsHelper
|
||||
data: { turbo_frame: "_top" }
|
||||
end
|
||||
|
||||
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, data: {}, **properties, &block)
|
||||
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", data: {}, **properties, &block)
|
||||
classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed)
|
||||
|
||||
data = {
|
||||
drag_and_drop_target: "container",
|
||||
navigable_list_target: "item",
|
||||
column_name: name,
|
||||
drag_and_drop_url: drop_url
|
||||
drag_and_drop_url: drop_url,
|
||||
drag_and_drop_css_variable_name: "--card-color",
|
||||
drag_and_drop_css_variable_value: card_color
|
||||
}.merge(data)
|
||||
|
||||
data[:action] = token_list(
|
||||
@@ -28,7 +30,8 @@ module ColumnsHelper
|
||||
|
||||
tag.section(id: id, class: classes, tabindex: "0", "aria-selected": selected, data: data, **properties) do
|
||||
tag.div(class: "cards__transition-container", data: {
|
||||
controller: "navigable-list",
|
||||
controller: "navigable-list css-variable-counter",
|
||||
css_variable_counter_property_name_value: "--card-count",
|
||||
navigable_list_supports_horizontal_navigation_value: "false",
|
||||
navigable_list_prevent_handled_keys_value: "true",
|
||||
navigable_list_auto_select_value: "false",
|
||||
|
||||
@@ -9,7 +9,7 @@ module HtmlHelper
|
||||
|
||||
private
|
||||
EXCLUDED_ELEMENTS = %w[ a figcaption pre code ]
|
||||
EMAIL_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/
|
||||
EMAIL_AUTOLINK_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/
|
||||
URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https])
|
||||
|
||||
def auto_link(fragment)
|
||||
@@ -48,7 +48,7 @@ module HtmlHelper
|
||||
end
|
||||
|
||||
def auto_link_emails(text)
|
||||
text.gsub!(EMAIL_REGEXP) do |match|
|
||||
text.gsub!(EMAIL_AUTOLINK_REGEXP) do |match|
|
||||
%(<a href="mailto:#{match}">#{match}</a>)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
module LoginHelper
|
||||
def login_url
|
||||
new_session_path(script_name: nil)
|
||||
# Use main_app because this helper may be invoked from an engine controller
|
||||
# that inherits from AdminController.
|
||||
main_app.new_session_path(script_name: nil)
|
||||
end
|
||||
|
||||
def logout_url
|
||||
new_session_path
|
||||
main_app.new_session_path
|
||||
end
|
||||
|
||||
def redirect_to_login_url
|
||||
|
||||
@@ -33,8 +33,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
get #entropyCleanupInDays() {
|
||||
this.entropyCleanupInDays ??= signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt))
|
||||
return this.entropyCleanupInDays
|
||||
return signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt))
|
||||
}
|
||||
|
||||
#showEntropy() {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { debounce } from "helpers/timing_helpers"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "item", "counter" ]
|
||||
static values = {
|
||||
propertyName: String,
|
||||
maxValue: { type: Number, default: 20 }
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.#updateCounter = debounce(this.#updateCounter.bind(this), 50)
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.#updateCounter()
|
||||
}
|
||||
|
||||
itemTargetConnected() {
|
||||
this.#updateCounter()
|
||||
}
|
||||
|
||||
itemTargetDisconnected() {
|
||||
this.#updateCounter()
|
||||
}
|
||||
|
||||
#updateCounter = () => {
|
||||
if (!this.hasCounterTarget) return
|
||||
|
||||
const count = Math.min(this.itemTargets.length, this.maxValueValue)
|
||||
this.counterTarget.style.setProperty(this.propertyNameValue, count)
|
||||
}
|
||||
}
|
||||
@@ -57,4 +57,8 @@ export default class extends Controller {
|
||||
loadLazyFrames() {
|
||||
Array.from(this.dialogTarget.querySelectorAll("turbo-frame")).forEach(frame => { frame.loading = "eager" })
|
||||
}
|
||||
|
||||
captureKey(event) {
|
||||
if (event.key !== "Escape") { event.stopPropagation() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,14 @@ export default class extends Controller {
|
||||
await nextFrame()
|
||||
this.dragItem = this.#itemContaining(event.target)
|
||||
this.sourceContainer = this.#containerContaining(this.dragItem)
|
||||
this.originalDraggedItemCssVariable = this.#containerCssVariableFor(this.sourceContainer)
|
||||
this.dragItem.classList.add(this.draggedItemClass)
|
||||
}
|
||||
|
||||
dragOver(event) {
|
||||
event.preventDefault()
|
||||
if (!this.dragItem) { return }
|
||||
|
||||
const container = this.#containerContaining(event.target)
|
||||
this.#clearContainerHoverClasses()
|
||||
|
||||
@@ -28,32 +31,39 @@ export default class extends Controller {
|
||||
|
||||
if (container !== this.sourceContainer) {
|
||||
container.classList.add(this.hoverContainerClass)
|
||||
this.#applyContainerCssVariableToDraggedItem(container)
|
||||
} else {
|
||||
this.#restoreOriginalDraggedItemCssVariable()
|
||||
}
|
||||
}
|
||||
|
||||
async drop(event) {
|
||||
const container = this.#containerContaining(event.target)
|
||||
const targetContainer = this.#containerContaining(event.target)
|
||||
|
||||
if (!container || container === this.sourceContainer) { return }
|
||||
if (!targetContainer || targetContainer === this.sourceContainer) { return }
|
||||
|
||||
this.wasDropped = true
|
||||
this.#increaseCounter(targetContainer)
|
||||
this.#decreaseCounter(this.sourceContainer)
|
||||
|
||||
const sourceContainer = this.sourceContainer
|
||||
await this.#submitDropRequest(this.dragItem, container)
|
||||
this.#reloadSourceFrame(sourceContainer);
|
||||
this.#insertDraggedItem(targetContainer, this.dragItem)
|
||||
await this.#submitDropRequest(this.dragItem, targetContainer)
|
||||
this.#reloadSourceFrame(sourceContainer)
|
||||
}
|
||||
|
||||
dragEnd() {
|
||||
this.dragItem.classList.remove(this.draggedItemClass)
|
||||
this.#clearContainerHoverClasses()
|
||||
|
||||
if (this.wasDropped) {
|
||||
this.dragItem.remove()
|
||||
if (!this.wasDropped) {
|
||||
this.#restoreOriginalDraggedItemCssVariable()
|
||||
}
|
||||
|
||||
this.sourceContainer = null
|
||||
this.dragItem = null
|
||||
this.wasDropped = false
|
||||
this.originalDraggedItemCssVariable = null
|
||||
}
|
||||
|
||||
#itemContaining(element) {
|
||||
@@ -68,6 +78,63 @@ export default class extends Controller {
|
||||
this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass))
|
||||
}
|
||||
|
||||
#applyContainerCssVariableToDraggedItem(container) {
|
||||
const cssVariable = this.#containerCssVariableFor(container)
|
||||
if (cssVariable) {
|
||||
this.dragItem.style.setProperty(cssVariable.name, cssVariable.value)
|
||||
}
|
||||
}
|
||||
|
||||
#restoreOriginalDraggedItemCssVariable() {
|
||||
if (this.originalDraggedItemCssVariable) {
|
||||
const { name, value } = this.originalDraggedItemCssVariable
|
||||
this.dragItem.style.setProperty(name, value)
|
||||
}
|
||||
}
|
||||
|
||||
#containerCssVariableFor(container) {
|
||||
const { dragAndDropCssVariableName, dragAndDropCssVariableValue } = container.dataset
|
||||
if (dragAndDropCssVariableName && dragAndDropCssVariableValue) {
|
||||
return { name: dragAndDropCssVariableName, value: dragAndDropCssVariableValue }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
#increaseCounter(container) {
|
||||
this.#modifyCounter(container, count => count + 1)
|
||||
}
|
||||
|
||||
#decreaseCounter(container) {
|
||||
this.#modifyCounter(container, count => Math.max(0, count - 1))
|
||||
}
|
||||
|
||||
#modifyCounter(container, fn) {
|
||||
const counterElement = container.querySelector("[data-drag-and-drop-counter]")
|
||||
if (counterElement) {
|
||||
const currentValue = counterElement.textContent.trim()
|
||||
|
||||
if (!/^\d+$/.test(currentValue)) return
|
||||
|
||||
counterElement.textContent = fn(parseInt(currentValue))
|
||||
}
|
||||
}
|
||||
|
||||
#insertDraggedItem(container, item) {
|
||||
const itemContainer = container.querySelector("[data-drag-drop-item-container]")
|
||||
const topItems = itemContainer.querySelectorAll("[data-drag-and-drop-top]")
|
||||
const firstTopItem = topItems[0]
|
||||
const lastTopItem = topItems[topItems.length - 1]
|
||||
|
||||
const isTopItem = item.hasAttribute("data-drag-and-drop-top")
|
||||
const referenceItem = isTopItem ? firstTopItem : lastTopItem
|
||||
|
||||
if (referenceItem) {
|
||||
referenceItem[isTopItem ? "before" : "after"](item)
|
||||
} else {
|
||||
itemContainer.prepend(item)
|
||||
}
|
||||
}
|
||||
|
||||
async #submitDropRequest(item, container) {
|
||||
const body = new FormData()
|
||||
const id = item.dataset.id
|
||||
@@ -80,18 +147,4 @@ export default class extends Controller {
|
||||
const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]")
|
||||
if (frame) frame.reload()
|
||||
}
|
||||
|
||||
#decreaseCounter(sourceContainer) {
|
||||
const counterElement = sourceContainer.querySelector("[data-drag-and-drop-counter]")
|
||||
if (counterElement) {
|
||||
const currentValue = counterElement.textContent.trim()
|
||||
|
||||
if (!/^\d+$/.test(currentValue)) return
|
||||
|
||||
const count = parseInt(currentValue)
|
||||
if (count > 0) {
|
||||
counterElement.textContent = count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,13 @@ export default class extends Controller {
|
||||
|
||||
if (input) {
|
||||
const value = (input.value || "").trim()
|
||||
if (value.length === 0) {
|
||||
const isEmpty = value.length === 0
|
||||
|
||||
if (isEmpty) {
|
||||
event.preventDefault()
|
||||
input.setCustomValidity(input.dataset.validationMessage || "Please fill out this field")
|
||||
input.reportValidity()
|
||||
input.addEventListener("input", () => input.setCustomValidity(""), { once: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
module SmtpDeliveryErrorHandling
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# Retry delivery to possibly-unavailable remote mailservers.
|
||||
retry_on Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError, wait: :polynomially_longer
|
||||
|
||||
# Net::SMTPServerBusy is SMTP error code 4xx, a temporary error.
|
||||
# Common one we've seen is 452 4.3.1 Insufficient system storage.
|
||||
# Patiently retry.
|
||||
retry_on Net::SMTPServerBusy, wait: :polynomially_longer
|
||||
|
||||
# SMTP error 50x.
|
||||
rescue_from Net::SMTPSyntaxError do |error|
|
||||
case error.message
|
||||
when /\A501 5\.1\.3/
|
||||
# Ignore undeliverable email addresses.
|
||||
Sentry.capture_exception error, level: :info if Fizzy.saas?
|
||||
else
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
# SMTP error 5xx except 50x and 53x.
|
||||
# * 550 5.1.1: Unknown users
|
||||
# * 552 5.6.0: Message/headers too large
|
||||
rescue_from Net::SMTPFatalError do |error|
|
||||
case error.message
|
||||
when /\A550 5\.1\.1/, /\A552 5\.6\.0/, /\A555 5\.5\.4/
|
||||
Sentry.capture_exception error, level: :info if Fizzy.saas?
|
||||
else
|
||||
raise
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class Notification::Bundle::DeliverJob < ApplicationJob
|
||||
include SmtpDeliveryErrorHandling
|
||||
|
||||
queue_as :backend
|
||||
|
||||
def perform(bundle)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "Fizzy <support@fizzy.do>"
|
||||
default from: ENV.fetch("MAILER_FROM_ADDRESS", "Fizzy <support@fizzy.do>")
|
||||
|
||||
layout "mailer"
|
||||
append_view_path Rails.root.join("app/views/mailers")
|
||||
|
||||
@@ -35,7 +35,7 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
def system_user
|
||||
users.where(role: :system).first!
|
||||
users.find_by!(role: :system)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -7,10 +7,9 @@ class Account::JoinCode < ApplicationRecord
|
||||
|
||||
before_create :generate_code, if: -> { code.blank? }
|
||||
|
||||
def redeem
|
||||
def redeem_if(&block)
|
||||
transaction do
|
||||
increment!(:usage_count)
|
||||
yield account if block_given?
|
||||
increment!(:usage_count) if block.call(account)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@ class Account::Seeder
|
||||
|
||||
# Cards
|
||||
playground.cards.create! creator: creator, title: "Finally, watch this Fizzy orientation video", status: "published", description: <<~HTML
|
||||
<p>There’s a whole lot more you can do in Fizzy. In the video below 37signals founder and CEO, Jason Fried, will walk you through the basics in just 8 minutes.</p>
|
||||
<p>There’s a whole lot more you can do in Fizzy. In the video below, 37signals founder and CEO, Jason Fried, will walk you through the basics in just 17 minutes.</p>
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/videos/fizzyorientation-4k.mp4" caption="Fizzy orientation" content-type="video/mp4" filename="fizzyorientation-4k.mp4"></action-text-attachment>
|
||||
HTML
|
||||
|
||||
playground.cards.create! creator: creator, title: "Now, grab the invite link to invite someone else", status: "published", description: <<~HTML
|
||||
<p>Open Fizzy menu, select “<b><strong>+ Add people</b></strong>”, then copy the invite link. You can give this link to someone else so they can make an login for themselves in your account.</p>
|
||||
<p>Open the Fizzy menu, select “<b><strong>+ Add people</b></strong>”, then copy the invite link. You can give this link to someone else so they can make a login for themselves in your account.</p>
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/invite-link.gif" alt="Demo of copying invite link" caption="Get a link to invite co-workers" content-type="image/*" filename="invite-link.gif" presentation="gallery"></action-text-attachment>
|
||||
HTML
|
||||
|
||||
@@ -43,7 +43,7 @@ class Account::Seeder
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/back-to-home.gif" alt="Demo of visiting Home" caption="Go back to Home for Latest Activity" content-type="image/*" filename="back-to-home.gif" presentation="gallery"></action-text-attachment>
|
||||
HTML
|
||||
|
||||
playground.cards.create! creator: creator, title: "Now,check out all cards assigned to you", status: "published", description: <<~HTML
|
||||
playground.cards.create! creator: creator, title: "Now, check out all cards assigned to you", status: "published", description: <<~HTML
|
||||
<p>Pull down the Fizzy menu at the top of the screen, and select “<b><strong>Assigned to me</b></strong>” or just hit “2” on your keyboard any time.</p>
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/all-assigned.gif" alt="Demo of navigating to 'Assigned to Me'" caption="See all cards assigned to me" content-type="image/*" filename="all-assigned.gif" presentation="gallery"></action-text-attachment>
|
||||
HTML
|
||||
@@ -54,7 +54,7 @@ class Account::Seeder
|
||||
HTML
|
||||
|
||||
playground.cards.create! creator: creator, title: "Next, assign this card to yourself", status: "published", description: <<~HTML
|
||||
<p>Click the little head with the + next to it, pick yourself.</p>
|
||||
<p>Click the little head with the + next to it, then pick yourself.</p>
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/assign-to-self.gif" alt="Demo of assigning a card" caption="Assign this to yourself" content-type="image/*" filename="assign-to-self.gif" presentation="gallery"></action-text-attachment>
|
||||
HTML
|
||||
|
||||
|
||||
@@ -2,6 +2,4 @@ class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
|
||||
configure_replica_connections
|
||||
|
||||
attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
|
||||
end
|
||||
|
||||
@@ -3,5 +3,16 @@ module Card::Broadcastable
|
||||
|
||||
included do
|
||||
broadcasts_refreshes
|
||||
|
||||
before_update :remember_if_preview_changed
|
||||
end
|
||||
|
||||
private
|
||||
def remember_if_preview_changed
|
||||
@preview_changed ||= title_changed? || column_id_changed? || board_id_changed?
|
||||
end
|
||||
|
||||
def preview_changed?
|
||||
@preview_changed
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,6 @@ module Card::Eventable
|
||||
def touch_last_active_at
|
||||
# Not using touch so that we can detect attribute change on callbacks
|
||||
update!(last_active_at: Time.current)
|
||||
broadcast_activity
|
||||
end
|
||||
|
||||
private
|
||||
@@ -36,8 +35,4 @@ module Card::Eventable
|
||||
def create_system_comment_for(event)
|
||||
SystemCommenter.new(self, event).comment
|
||||
end
|
||||
|
||||
def broadcast_activity
|
||||
broadcast_render_later_to self, :activity, partial: "card/display/refresh_activity", locals: { card: self }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,8 @@ module Card::Pinnable
|
||||
|
||||
included do
|
||||
has_many :pins, dependent: :destroy
|
||||
|
||||
after_update_commit :broadcast_pin_updates, if: :preview_changed?
|
||||
end
|
||||
|
||||
def pinned_by?(user)
|
||||
@@ -20,4 +22,11 @@ module Card::Pinnable
|
||||
def unpin_by(user)
|
||||
pins.find_by(user: user).tap { it.destroy }
|
||||
end
|
||||
|
||||
private
|
||||
def broadcast_pin_updates
|
||||
pins.find_each do |pin|
|
||||
pin.broadcast_replace_later_to [ pin.user, :pins_tray ], partial: "my/pins/pin"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,8 +14,9 @@ module Card::Readable
|
||||
end
|
||||
|
||||
def remove_inaccessible_notifications
|
||||
User.find_each do |user|
|
||||
all_notifications_for(user).destroy_all unless accessible_to?(user)
|
||||
accessible_user_ids = board.accesses.pluck(:user_id)
|
||||
notification_sources.each do |sources|
|
||||
inaccessible_notifications_from(sources, accessible_user_ids).in_batches.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,6 +45,14 @@ module Card::Readable
|
||||
Event.where(eventable: comments)
|
||||
end
|
||||
|
||||
def inaccessible_notifications_from(sources, accessible_user_ids)
|
||||
Notification.where(source: sources).where.not(user_id: accessible_user_ids)
|
||||
end
|
||||
|
||||
def notification_sources
|
||||
[ events, comment_creation_events, mentions, comment_mentions ]
|
||||
end
|
||||
|
||||
def mention_notification_sources
|
||||
mentions.or(comment_mentions)
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@ module Card::Taggable
|
||||
end
|
||||
|
||||
def toggle_tag_with(title)
|
||||
tag = Tag.find_or_create_by!(title: title)
|
||||
tag = account.tags.find_or_create_by!(title: title)
|
||||
|
||||
transaction do
|
||||
if tagged_with?(tag)
|
||||
|
||||
@@ -12,11 +12,11 @@ class Current < ActiveSupport::CurrentAttributes
|
||||
end
|
||||
end
|
||||
|
||||
def with_account(value, &block)
|
||||
with(account: value, &block)
|
||||
def with_account(value, &)
|
||||
with(account: value, &)
|
||||
end
|
||||
|
||||
def without_account(&block)
|
||||
with(account: nil, &block)
|
||||
def without_account(&)
|
||||
with(account: nil, &)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,7 +24,7 @@ module Filter::Resources
|
||||
|
||||
def board_titles
|
||||
if boards.none?
|
||||
Board.one? ? [ Board.first.name ] : [ "all boards" ]
|
||||
creator.boards.one? ? [ creator.boards.first.name ] : [ "all boards" ]
|
||||
else
|
||||
boards.map(&:name)
|
||||
end
|
||||
|
||||
@@ -10,6 +10,7 @@ class Identity < ApplicationRecord
|
||||
|
||||
before_destroy :deactivate_users
|
||||
|
||||
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
normalizes :email_address, with: ->(value) { value.strip.downcase.presence }
|
||||
|
||||
def send_magic_link(**attributes)
|
||||
|
||||
@@ -5,11 +5,9 @@ module Identity::Joinable
|
||||
attributes[:name] ||= email_address
|
||||
|
||||
transaction do
|
||||
account.users.create!(**attributes, identity: self)
|
||||
account.users.find_or_create_by!(identity: self) do |user|
|
||||
user.assign_attributes(attributes)
|
||||
end.previously_new_record?
|
||||
end
|
||||
end
|
||||
|
||||
def member_of?(account)
|
||||
account.users.exists?(identity: self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ module MagicLink::Code
|
||||
|
||||
class << self
|
||||
def generate(length)
|
||||
length.times.map { CODE_ALPHABET.sample }.join
|
||||
Array.new(length) { CODE_ALPHABET[SecureRandom.random_number(CODE_ALPHABET.length)] }.join
|
||||
end
|
||||
|
||||
def sanitize(code)
|
||||
|
||||
@@ -2,8 +2,6 @@ module Search::Record::SQLite
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
# Override default UUID id attribute, as FTS5 uses rowid integer primary key
|
||||
attribute :id, :integer, default: nil
|
||||
attribute :result_title, :string
|
||||
attribute :result_content, :string
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
module SsrfProtection
|
||||
extend self
|
||||
|
||||
DNS_RESOLUTION_TIMEOUT = 2
|
||||
|
||||
DISALLOWED_IP_RANGES = [
|
||||
IPAddr.new("0.0.0.0/8") # Broadcasts
|
||||
].freeze
|
||||
|
||||
def resolve_public_ip(hostname)
|
||||
ip_addresses = resolve_dns(hostname)
|
||||
public_ips = ip_addresses.reject { |ip| private_address?(ip) }
|
||||
public_ips.first&.to_s
|
||||
end
|
||||
|
||||
def private_address?(ip)
|
||||
ip = IPAddr.new(ip.to_s) unless ip.is_a?(IPAddr)
|
||||
ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || in_disallowed_range?(ip)
|
||||
end
|
||||
|
||||
private
|
||||
def resolve_dns(hostname)
|
||||
ip_addresses = []
|
||||
|
||||
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
|
||||
dns.each_address(hostname) do |ip_address|
|
||||
ip_addresses << IPAddr.new(ip_address.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
ip_addresses
|
||||
end
|
||||
|
||||
def in_disallowed_range?(ip)
|
||||
DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
|
||||
end
|
||||
end
|
||||
+1
-5
@@ -1,12 +1,8 @@
|
||||
class User < ApplicationRecord
|
||||
include Accessor, Assignee, Attachable, Configurable, EmailAddressChangeable,
|
||||
include Accessor, Assignee, Attachable, Avatar, Configurable, EmailAddressChangeable,
|
||||
Mentionable, Named, Notifiable, Role, Searcher, Watcher
|
||||
include Timelined # Depends on Accessor
|
||||
|
||||
has_one_attached :avatar do |attachable|
|
||||
attachable.variant :thumb, resize_to_fill: [ 256, 256 ]
|
||||
end
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :identity, optional: true
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ module User::Accessor
|
||||
included do
|
||||
has_many :accesses, dependent: :destroy
|
||||
has_many :boards, through: :accesses
|
||||
has_many :accessible_columns, through: :boards, source: :columns
|
||||
has_many :accessible_cards, through: :boards, source: :cards
|
||||
has_many :accessible_comments, through: :accessible_cards, source: :comments
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
module User::Avatar
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
|
||||
|
||||
included do
|
||||
has_one_attached :avatar do |attachable|
|
||||
attachable.variant :thumb, resize_to_fill: [ 256, 256 ]
|
||||
end
|
||||
|
||||
validate :avatar_content_type_allowed
|
||||
end
|
||||
|
||||
def avatar_thumbnail
|
||||
avatar.variable? ? avatar.variant(:thumb) : avatar
|
||||
end
|
||||
|
||||
private
|
||||
def avatar_content_type_allowed
|
||||
if avatar.attached? && !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
|
||||
errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,14 +7,12 @@ module User::EmailAddressChangeable
|
||||
def change_email_address_using_token(token)
|
||||
parsed_token = SignedGlobalID.parse(token, for: EMAIL_CHANGE_TOKEN_PURPOSE)
|
||||
|
||||
if parsed_token.nil?
|
||||
raise ArgumentError, "The token is invalid"
|
||||
elsif parsed_token.find != self
|
||||
raise ArgumentError, "The token was generated for a different user"
|
||||
elsif identity.email_address != parsed_token.params.fetch("old_email_address")
|
||||
raise ArgumentError, "The token was generated for a different email address"
|
||||
old_email_address = parsed_token&.params&.fetch("old_email_address")
|
||||
new_email_address = parsed_token&.params&.fetch("new_email_address")
|
||||
|
||||
if parsed_token.nil? || parsed_token.find != self || identity.email_address != old_email_address
|
||||
false
|
||||
else
|
||||
new_email_address = parsed_token.params.fetch("new_email_address")
|
||||
change_email_address(new_email_address)
|
||||
end
|
||||
end
|
||||
@@ -37,8 +35,6 @@ module User::EmailAddressChangeable
|
||||
new_identity = Identity.find_or_create_by!(email_address: new_email_address)
|
||||
update!(identity: new_identity)
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,11 +2,6 @@ class Webhook::Delivery < ApplicationRecord
|
||||
STALE_TRESHOLD = 7.days
|
||||
USER_AGENT = "fizzy/1.0.0 Webhook"
|
||||
ENDPOINT_TIMEOUT = 7.seconds
|
||||
DNS_RESOLUTION_TIMEOUT = 2
|
||||
DISALLOWED_IP_RANGES = [
|
||||
# Broadcasts
|
||||
IPAddr.new("0.0.0.0/8")
|
||||
].freeze
|
||||
|
||||
belongs_to :account, default: -> { webhook.account }
|
||||
belongs_to :webhook
|
||||
@@ -54,14 +49,14 @@ class Webhook::Delivery < ApplicationRecord
|
||||
|
||||
private
|
||||
def perform_request
|
||||
if private_uri?
|
||||
if resolved_ip.nil?
|
||||
{ error: :private_uri }
|
||||
else
|
||||
response = http.request(
|
||||
Net::HTTP::Post.new(uri, headers).tap { |request| request.body = payload }
|
||||
)
|
||||
|
||||
{ code: response.code.to_i }
|
||||
{ code: response.code.to_i }
|
||||
end
|
||||
rescue Resolv::ResolvTimeout, Resolv::ResolvError, SocketError
|
||||
{ error: :dns_lookup_failed }
|
||||
@@ -73,18 +68,9 @@ class Webhook::Delivery < ApplicationRecord
|
||||
{ error: :failed_tls }
|
||||
end
|
||||
|
||||
def private_uri?
|
||||
ip_addresses = []
|
||||
|
||||
Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns|
|
||||
dns.each_address(uri.host) do |ip_address|
|
||||
ip_addresses << IPAddr.new(ip_address)
|
||||
end
|
||||
end
|
||||
|
||||
ip_addresses.any? do |ip|
|
||||
ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) }
|
||||
end
|
||||
def resolved_ip
|
||||
return @resolved_ip if defined?(@resolved_ip)
|
||||
@resolved_ip = SsrfProtection.resolve_public_ip(uri.host)
|
||||
end
|
||||
|
||||
def uri
|
||||
@@ -92,7 +78,7 @@ class Webhook::Delivery < ApplicationRecord
|
||||
end
|
||||
|
||||
def http
|
||||
Net::HTTP.new(uri.host, uri.port).tap do |http|
|
||||
Net::HTTP.new(uri.host, uri.port, ipaddr: resolved_ip).tap do |http|
|
||||
http.use_ssl = (uri.scheme == "https")
|
||||
http.open_timeout = ENDPOINT_TIMEOUT
|
||||
http.read_timeout = ENDPOINT_TIMEOUT
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<figure class="attachment attachment--preview">
|
||||
<%= image_tag remote_image.url, width: remote_image.width, height: remote_image.height %>
|
||||
<%= image_tag remote_image.url, skip_pipeline: true, width: remote_image.width, height: remote_image.height %>
|
||||
<% if caption = remote_image.try(:caption) %>
|
||||
<figcaption class="attachment__caption">
|
||||
<%= caption %>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<div class="cards__list" data-drag-drop-item-container>
|
||||
<div class="blank-slate blank-slate--drag card card--hide-unless-empty">
|
||||
<p>Drag cards here</p>
|
||||
</div>
|
||||
<div class="blank-slate blank-slate--empty card--hide-unless-empty">No cards here</div>
|
||||
</div>
|
||||
@@ -1,3 +1,3 @@
|
||||
<div class="cards__list">
|
||||
<div class="cards__list" data-drag-drop-item-container >
|
||||
<%= render "cards/display/previews", cards: cards, draggable: draggable %>
|
||||
</div>
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="cards__list">
|
||||
<div class="card blank-slate blank-slate--card">
|
||||
<p class="txt-align-center">Drag cards here</p>
|
||||
<p class="txt-align-center blank-slate-for-grid">Nothing here</p>
|
||||
</div>
|
||||
</div>
|
||||
<%= render "boards/columns/empty_placeholder" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="cards__list">
|
||||
<div class="card blank-slate blank-slate--card">
|
||||
<p class="txt-align-center">Drag cards here</p>
|
||||
<p class="txt-align-center blank-slate-for-grid">Nothing here</p>
|
||||
</div>
|
||||
</div>
|
||||
<%= render "boards/columns/empty_placeholder" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -17,12 +17,7 @@
|
||||
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="cards__list">
|
||||
<div class="card blank-slate blank-slate--card">
|
||||
<p class="txt-align-center">Drag cards here</p>
|
||||
<p class="txt-align-center blank-slate-for-grid">Nothing here</p>
|
||||
</div>
|
||||
</div>
|
||||
<%= render "boards/columns/empty_placeholder" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="card blank-slate blank-slate--card">
|
||||
<p class="txt-align-center blank-slate-for-grid">Nothing here</p>
|
||||
</div>
|
||||
<%= render "boards/columns/empty_placeholder" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= form_with model: board, class: "txt-align-center margin-block-start-auto", method: :delete do |form| %>
|
||||
<%= form.button class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to permanently delete this board?" } do %>
|
||||
<%= form.button class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to permanently delete this board and all the cards on it? This can't be undone." } do %>
|
||||
<%= icon_tag "trash" %>
|
||||
<span>Delete this board</span>
|
||||
<% end %>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<% @page_title = "Create a new board" %>
|
||||
|
||||
<div class="panel panel--centered">
|
||||
<%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form" } do |form| %>
|
||||
<%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>
|
||||
<h1 class="txt-x-large margin-none font-weight-black"><%= @page_title %></h1>
|
||||
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off",placeholder: "Name it…", data: { action: "keydown.esc@document->form#cancel" } %>
|
||||
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can‘t be blank" } %>
|
||||
|
||||
<button type="submit" class="btn btn--link center">
|
||||
<span>Create board</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<%= column_tag id: dom_id(column), name: column.name, drop_url: columns_card_drops_column_path("__id__", column_id: column.id), class: "cards--doing", style: "--card-color: #{column.color};",
|
||||
<%= column_tag id: dom_id(column), name: column.name, drop_url: columns_card_drops_column_path("__id__", column_id: column.id), card_color: column.color.to_s,
|
||||
class: "cards--doing", style: "--card-color: #{column.color};",
|
||||
data: {
|
||||
drag_and_strum_target: "container",
|
||||
collapsible_columns_target: "column",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<button class="cards__expander btn btn--plain" data-collapsible-columns-target="button" data-action="click->collapsible-columns#toggle click->navigable-list#selectCurrentOrReset"
|
||||
<button class="cards__expander btn btn--plain" data-collapsible-columns-target="button" data-css-variable-counter-target="counter" data-action="click->collapsible-columns#toggle click->navigable-list#selectCurrentOrReset"
|
||||
style="--card-count: <%= [ count, 20 ].min %>" aria-controls="<%= column_id %>" aria-expanded="false">
|
||||
<span class="cards__expander-count" data-drag-and-drop-counter="true"><%= count > 99 ? "99+" : count %></span>
|
||||
<h2 class="cards__expander-title" data-collapsible-columns-target="title">
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<%= with_automatic_pagination :filtered_cards_paginated_container, page do %>
|
||||
<%= render "cards/display/previews", cards: page.records, draggable: true %>
|
||||
<% end %>
|
||||
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
|
||||
</section>
|
||||
|
||||
@@ -13,5 +13,8 @@
|
||||
<%= with_automatic_pagination "the-stream", @page do %>
|
||||
<%= render "boards/columns/list", cards: page.records, draggable: true %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="cards__list" data-drag-drop-item-container>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= form_with model: [board, column], data: { controller: "form", action: "turbo:submit-end->dialog#close turbo:submit-end->form#reset" } do |form| %>
|
||||
<%= form_with model: [board, column], data: { controller: "form", action: "turbo:submit-end->dialog#close turbo:submit-end->form#reset keydown->dialog#captureKey" } do |form| %>
|
||||
<%= form.text_field :name, class: "input", placeholder: "Name this column", value: column.name,
|
||||
required: true, autocomplete: "off", pattern: ".*\\S.*", title: "Column name cannot be blank", data: { action: "focus->form#select" } %>
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<%= turbo_stream.remove dom_id(card, "bubble") %>
|
||||
<%= turbo_stream.replace dom_id(card, :card_closure_toggle) do %>
|
||||
<%= render "cards/container/closure", card: card %>
|
||||
<% end %>
|
||||
@@ -1,6 +1,6 @@
|
||||
<% draggable = local_assigns.fetch(:draggable, false) && card.published? %>
|
||||
|
||||
<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item" }, tabindex: 0 do %>
|
||||
<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item", css_variable_counter_target: "item" }, tabindex: 0 do %>
|
||||
<div class="flex flex-column flex-item-grow max-inline-size">
|
||||
<%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %>
|
||||
<span class="for-screen-reader"><%= card.title %></span>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<ol class="steps txt-small margin-block-start-auto">
|
||||
<%= render partial: "cards/steps/step", collection: card.steps, as: :step %>
|
||||
|
||||
<li id="<%= dom_id(card, :new_step) %>" class="step">
|
||||
<input type="checkbox" class="step__checkbox" disabled>
|
||||
<%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit turbo:submit-end->form#reset" } do |form| %>
|
||||
<%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true" }, aria: { label: "Add a step" } %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% unless card.closed? %>
|
||||
<li id="<%= dom_id(card, :new_step) %>" class="step">
|
||||
<input type="checkbox" class="step__checkbox" disabled>
|
||||
<%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit turbo:submit-end->form#reset" } do |form| %>
|
||||
<%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true" }, aria: { label: "Add a step" } %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ol>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class: "bubble",
|
||||
data: {
|
||||
controller: "bubble",
|
||||
action: "turbo:morph-element->bubble#update",
|
||||
action: "turbo:morph-element->bubble#update:self",
|
||||
bubble_entropy_value: entropy_bubble_options_for(card).to_json,
|
||||
bubble_stalled_value: stalled_bubble_options_for(card)&.to_json
|
||||
} do %>
|
||||
|
||||
@@ -22,5 +22,6 @@
|
||||
<%= with_automatic_pagination :cards_paginated_container, @page do %>
|
||||
<%= render "cards/display/previews", cards: @page.records, draggable: true %>
|
||||
<% end %>
|
||||
<div class="blank-slate blank-slate--empty blank-slate--filters">No cards match this filter</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<%= image_tag remote_image.url, class: "attachment attachment--image", width: remote_image.width, height: remote_image.height %>
|
||||
<%= image_tag remote_image.url, skip_pipeline: true, class: "attachment attachment--image", width: remote_image.width, height: remote_image.height %>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<%= yield :header %>
|
||||
</header>
|
||||
|
||||
<%= render "layouts/shared/flash" %>
|
||||
|
||||
<main id="main">
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<% @hide_footer_frames = true %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= back_link_to "Home", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
|
||||
</div>
|
||||
|
||||
<h1 class="header__title"><%= @page_title %></h1>
|
||||
|
||||
<div class="header__actions header__actions--end">
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
<%= render "public/boards/columns/list", cards: @page.records %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="card blank-slate blank-slate--card">
|
||||
<p class="txt-align-center txt-normal">No cards here</p>
|
||||
</div>
|
||||
<div class="blank-slate blank-slate--empty">No cards here</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
<%= render "public/boards/columns/list", cards: @page.records %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="card blank-slate blank-slate--card">
|
||||
<p class="txt-align-center txt-normal">No cards here</p>
|
||||
</div>
|
||||
<div class="blank-slate blank-slate--empty">No cards here</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
<%= render "public/boards/columns/list", cards: @page.records %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="card blank-slate blank-slate--card">
|
||||
<p class="txt-align-center txt-normal">No cards here</p>
|
||||
</div>
|
||||
<div class="blank-slate blank-slate--empty">No cards here</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
<%= render "public/boards/columns/list", cards: @page.records %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="card blank-slate blank-slate--card">
|
||||
<p class="txt-align-center txt-normal">No cards here</p>
|
||||
</div>
|
||||
<div class="blank-slate blank-slate--empty">No cards here</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<%= turbo_frame_tag :cards_container do %>
|
||||
<div class="card-columns hide-scrollbar" data-controller="collapsible-columns" data-collapsible-columns-board-value="<%= board.id %>" data-collapsible-columns-collapsed-class="is-collapsed" data-collapsible-columns-no-transitions-class="no-transitions">
|
||||
<div class="card-columns hide-scrollbar"
|
||||
data-controller="collapsible-columns"
|
||||
data-collapsible-columns-board-value="<%= board.id %>"
|
||||
data-collapsible-columns-collapsed-class="is-collapsed"
|
||||
data-collapsible-columns-no-transitions-class="no-transitions"
|
||||
data-collapsible-columns-title-not-visible-class="is-off-screen">
|
||||
|
||||
<div class="card-columns__left">
|
||||
<%= render "public/boards/show/not_now", board: board %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% @page_title = "Check your email" %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] %>">
|
||||
<div class="panel panel--centered flex flex-column gap-half <%= "shake" if flash[:alert] || flash[:shake] %>">
|
||||
<header>
|
||||
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
|
||||
<p class="margin-none-block-start txt-medium">Then enter the verification code included in the email below:</p>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<label class="avatar btn btn--circle input--file txt-xx-large center fill-white">
|
||||
<%= image_tag user_avatar_path(@user), aria: { hidden: "true" }, class: "avatar", size: 128, data: { upload_preview_target: "image" } %>
|
||||
<%= form.file_field :avatar, id: "file", class: "input", accept: "image/*",
|
||||
<%= form.file_field :avatar, id: "file", class: "input", accept: "image/jpeg, image/png, image/gif, image/webp",
|
||||
data: { upload_preview_target: "input", action: "upload-preview#previewImage" } %>
|
||||
<span class="for-screen-reader">Profile avatar for <%= @user.name %></span>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<% @page_title = "Link expired" %>
|
||||
|
||||
<div class="panel panel--centered center flex flex-column gap-half" style="--panel-size: 50ch;">
|
||||
<h1 class="txt-x-large font-weight-black margin-none">
|
||||
<%= @page_title %>
|
||||
</h1>
|
||||
<p class="margin-none-block-start margin-block-end-half">
|
||||
That email confirmation link is no longer valid—they expire after 30 minues. You’ll have to try again.
|
||||
</p>
|
||||
|
||||
<%= link_to "Change my email address", new_user_email_address_path(user_id: @user, script_name: @user.account.slug), class: "btn btn--link center" %>
|
||||
|
||||
<p class="txt-small txt-subtle">
|
||||
If you get stuck, <%= mail_to "support@fizzy.do", "send us an email" %> and we’ll get you back on track.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,11 +1,11 @@
|
||||
<% @page_title = "Confirm email change" %>
|
||||
|
||||
<div class="panel shadow center flex flex-column gap-half" style="--panel-size: 45ch;">
|
||||
<div class="panel panel--centered center flex flex-column gap-half" style="--panel-size: 45ch;">
|
||||
<header>
|
||||
<h1 class="txt-x-large font-weight-black margin-none">
|
||||
<%= @page_title %>
|
||||
</h1>
|
||||
<p class="margin-none">Just a sec while we confirm your new email address.</p>
|
||||
<p class="margin-none-block-start margin-block-end-half">Just a sec while we confirm your new email address.</p>
|
||||
</header>
|
||||
|
||||
<%= form_with url: user_email_address_confirmation_path(user_id: @user.id), method: :post, data: { controller: "form auto-submit" } do |form| %>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap-half">
|
||||
<div class="panel panel--centered flex flex-column gap-half" style="--panel-size: 50ch;">
|
||||
<h1 class="txt-x-large font-weight-black margin-none">Check your email</h1>
|
||||
<p class="margin-none">We just sent an email to <strong><%= params[:email_address] %></strong></p>
|
||||
<p class="margin-none-block-start">Hit the link in the email to confirm this is the email address you want to use with Fizzy going forward.</p>
|
||||
<p class="margin-none-block-start margin-block-end-half">Hit the link in the email to confirm this is the email address you want to use with Fizzy going forward.</p>
|
||||
|
||||
<%= link_to "Done", edit_user_path(@user, script_name: @user.account.slug), class: "btn btn--link center" %>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap-half">
|
||||
<div class="panel panel--centered flex flex-column gap-half" style="--panel-size: 45ch;">
|
||||
<h1 class="txt-x-large font-weight-black margin-none">
|
||||
<%= @page_title %>
|
||||
</h1>
|
||||
@@ -18,7 +18,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="margin-none">Enter your new email address, then hit the link to confirm it</p>
|
||||
<p class="margin-none-block-start margin-block-end-half">Enter your new email address, then check your email to confirm the change.</p>
|
||||
|
||||
<button type="submit" class="btn btn--link center" data-form-target="submit">
|
||||
<span>Continue</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
bin/rails runner - <<EOF
|
||||
puts "Login with david@37signals.com to: http://fizzy.localhost:3006/"
|
||||
puts "Login with david@example.com to: http://fizzy.localhost:3006/"
|
||||
EOF
|
||||
|
||||
if [ -f tmp/solid-queue.txt ]; then
|
||||
|
||||
@@ -31,6 +31,36 @@ if ! command -v gum &>/dev/null; then
|
||||
echo
|
||||
fi
|
||||
|
||||
# Install mise if needed
|
||||
if ! command -v mise &>/dev/null; then
|
||||
echo
|
||||
echo "▸ Installing mise"
|
||||
if command -v pacman &>/dev/null; then
|
||||
sudo pacman -S --noconfirm mise
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install mise
|
||||
else
|
||||
echo "Please install mise: https://mise.jdx.dev/installing-mise.html#installation-methods"
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
|
||||
# Install gh if needed
|
||||
if ! command -v gh &>/dev/null; then
|
||||
echo
|
||||
echo "▸ Installing GitHub CLI"
|
||||
if command -v pacman &>/dev/null; then
|
||||
sudo pacman -S --noconfirm gh
|
||||
elif command -v brew &>/dev/null; then
|
||||
brew install gh
|
||||
else
|
||||
echo "Please install GitHub CLI: https://github.com/cli/cli#installation"
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
fi
|
||||
|
||||
step() {
|
||||
local step_name="$1"
|
||||
shift
|
||||
@@ -89,11 +119,19 @@ if which pacman >/dev/null 2>&1; then
|
||||
if ! pacman -Q "${packages[@]}" >/dev/null 2>&1; then
|
||||
step "Installing packages" sudo pacman -S --noconfirm --needed "${packages[@]}"
|
||||
fi
|
||||
elif which brew >/dev/null 2>&1; then
|
||||
packages=(imagemagick openslide vips gitleaks)
|
||||
missing_packages=()
|
||||
for pkg in "${packages[@]}"; do
|
||||
if ! brew list "$pkg" &>/dev/null; then
|
||||
missing_packages+=("$pkg")
|
||||
fi
|
||||
done
|
||||
if [ ${#missing_packages[@]} -gt 0 ]; then
|
||||
step "Installing packages" brew install "${missing_packages[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure gh-signoff is installed and up to date
|
||||
step "Set up gh-signoff" bash -c "gh extension install basecamp/gh-signoff || gh extension upgrade basecamp/gh-signoff"
|
||||
|
||||
bundle config set --local auto_install true
|
||||
step "Installing RubyGems" bundle install
|
||||
|
||||
|
||||
@@ -33,3 +33,11 @@ production:
|
||||
<<: *default
|
||||
database: fizzy_production_cable
|
||||
migrations_paths: db/cable_migrate
|
||||
queue:
|
||||
<<: *default
|
||||
database: fizzy_production_queue
|
||||
migrations_paths: db/queue_migrate
|
||||
cache:
|
||||
<<: *default
|
||||
database: fizzy_production_cache
|
||||
migrations_paths: db/cache_migrate
|
||||
@@ -1,14 +1,10 @@
|
||||
# Inject UUID primary key support into Rails framework models
|
||||
# Inject account associations into Rails framework models
|
||||
Rails.application.config.to_prepare do
|
||||
ActionText::RichText.attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
|
||||
ActionText::RichText.belongs_to :account, default: -> { record.account }
|
||||
|
||||
ActiveStorage::Attachment.attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
|
||||
ActiveStorage::Attachment.belongs_to :account, default: -> { record.account }
|
||||
|
||||
ActiveStorage::Blob.attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
|
||||
ActiveStorage::Blob.belongs_to :account, default: -> { Current.account }
|
||||
|
||||
ActiveStorage::VariantRecord.attribute :id, :uuid, default: -> { ActiveRecord::Type::Uuid.generate }
|
||||
ActiveStorage::VariantRecord.belongs_to :account, default: -> { blob.account }
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user