Merge branch 'main' into fix-blurred-nav-menu-in-safari

This commit is contained in:
Andy Smith
2026-03-03 09:52:12 -06:00
committed by GitHub
169 changed files with 3159 additions and 956 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ jobs:
- name: Build and push (${{ matrix.platform }})
id: build
uses: docker/build-push-action@v6.18.0
uses: docker/build-push-action@v6.19.2
with:
context: .
file: Dockerfile
+9 -1
View File
@@ -50,6 +50,14 @@ bin/jobs # Manage Solid Queue jobs
bin/kamal deploy # Deploy (requires 1Password CLI for secrets)
```
## Deploy
Default branch: `main`
Pre-deploy: `bin/rails saas:enable`
Deploy: `bin/kamal deploy -d <destination>`
Destinations: production, staging, beta, beta1, beta2, beta3, beta4
Note: `beta` is a template requiring `BETA_NUMBER` env var; typical targets are `beta1`-`beta4`.
## Architecture Overview
### Multi-Tenancy (URL-Based)
@@ -140,7 +148,7 @@ Key recurring tasks (via `config/recurring.yml`):
### Imports and exports
Allow people to move between OSS and SAAS Fizzy instances:
- Exports/Imports can be wirtten to/read from local or S3 storage depending on the config of the instance (both myst be supported)
- Exports/Imports can be written to/read from local or S3 storage depending on the config of the instance (both must be supported)
- Must be able to handle very large ZIP files (500+GB)
- Models in `app/models/account/data_transfer/`, `app/models/zip_file`
+3 -3
View File
@@ -16,10 +16,10 @@ gem "kamal", require: false
gem "puma", ">= 5.0"
gem "solid_cable", ">= 3.0"
gem "solid_cache", "~> 1.0"
gem "solid_queue", "~> 1.2"
gem "solid_queue", "~> 1.3"
gem "sqlite3", ">= 2.0"
gem "thruster", require: false
gem "trilogy", "~> 2.9"
gem "trilogy", "~> 2.10"
# Features
gem "bcrypt", "~> 3.1.7"
@@ -54,7 +54,7 @@ group :development, :test do
end
group :development do
gem "web-console"
gem "web-console", github: "rails/web-console"
end
group :test do
+71 -64
View File
@@ -1,8 +1,8 @@
GIT
remote: https://github.com/basecamp/lexxy
revision: 4bcf8d0213971560e90734493f8bd835addc3642
revision: b7f0f1429df5fa425eac043191ae2b65edee7488
specs:
lexxy (0.7.4.beta)
lexxy (0.7.6.beta)
rails (>= 8.0.2)
GIT
@@ -13,7 +13,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 60d92e4e7dfe923528ccdccc18820ccfe841b7b8
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -47,7 +47,7 @@ GIT
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.2.0.alpha)
action_text-trix (~> 2.1.15)
action_text-trix (~> 2.1.16)
actionpack (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activestorage (= 8.2.0.alpha)
@@ -113,18 +113,27 @@ GIT
tsort (>= 0.2)
zeitwerk (~> 2.6)
GIT
remote: https://github.com/rails/web-console.git
revision: 90e3474306f2367cfeaf2875e91e2bc2d71b5f0f
specs:
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.16)
railties
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1203.0)
aws-sdk-core (3.241.3)
aws-partitions (1.1218.0)
aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -132,11 +141,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -147,9 +156,9 @@ GEM
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.22.0)
bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (8.0.1)
brakeman (8.0.2)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -180,7 +189,7 @@ GEM
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
erb (6.0.1)
erb (6.0.2)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
@@ -208,20 +217,21 @@ GEM
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.2.2)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.16.0)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
jmespath (1.6.2)
json (2.18.0)
json (2.18.1)
jwt (3.1.2)
base64
kamal (2.10.1)
@@ -258,7 +268,8 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.1)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
@@ -271,12 +282,12 @@ GEM
stimulus-rails
turbo-rails
mittens (0.3.1)
mocha (3.0.1)
mocha (3.0.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
net-http-persistent (4.0.8)
connection_pool (>= 2.2.4, < 4)
net-imap (0.6.2)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -291,21 +302,21 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-aarch64-linux-musl)
nokogiri (1.19.1-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.0-arm-linux-gnu)
nokogiri (1.19.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-arm-linux-musl)
nokogiri (1.19.1-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.0-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-darwin)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4)
openssl (4.0.0)
ostruct (0.6.3)
@@ -319,7 +330,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.8.0)
prism (1.9.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -327,12 +338,12 @@ GEM
psych (5.3.1)
date
stringio
public_suffix (6.0.2)
puma (7.1.0)
public_suffix (7.0.2)
puma (7.2.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.4)
rack (3.2.5)
rack-mini-profiler (4.0.1)
rack (>= 1.2.0)
rack-session (2.1.1)
@@ -346,12 +357,12 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
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 (7.0.3)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
@@ -399,7 +410,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.40.0)
selenium-webdriver (4.41.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -414,21 +425,21 @@ GEM
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.2.4)
solid_queue (1.3.2)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.8.0-aarch64-linux-gnu)
sqlite3 (2.8.0-aarch64-linux-musl)
sqlite3 (2.8.0-arm-linux-gnu)
sqlite3 (2.8.0-arm-linux-musl)
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
sshkit (1.25.0)
base64
logger
@@ -436,20 +447,21 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
stackprof (0.2.27)
stackprof (0.2.28)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-darwin)
thruster (0.1.17-x86_64-linux)
thruster (0.1.18)
thruster (0.1.18-aarch64-linux)
thruster (0.1.18-arm64-darwin)
thruster (0.1.18-x86_64-darwin)
thruster (0.1.18-x86_64-linux)
timeout (0.6.0)
trilogy (2.9.0)
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.21)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -459,11 +471,6 @@ GEM
unicode-emoji (4.1.0)
uri (1.1.1)
vcr (6.4.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
web-push (3.1.0)
jwt (~> 3.0)
openssl (>= 3.0)
@@ -478,7 +485,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.4)
zeitwerk (2.7.5)
zip_kit (6.3.4)
PLATFORMS
@@ -488,7 +495,7 @@ PLATFORMS
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin-25
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
@@ -526,16 +533,16 @@ DEPENDENCIES
selenium-webdriver
solid_cable (>= 3.0)
solid_cache (~> 1.0)
solid_queue (~> 1.2)
solid_queue (~> 1.3)
sqlite3 (>= 2.0)
stackprof
stimulus-rails
thruster
trilogy (~> 2.9)
trilogy (~> 2.10)
turbo-rails
useragent!
vcr
web-console
web-console!
web-push
webmock
zip_kit
+4
View File
@@ -4,12 +4,16 @@ eval_gemfile "Gemfile"
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
gem "activeresource", require: "active_resource"
gem "actionpack-xml_parser" # needed by queenbee for XML request body parsing
gem "stripe", "~> 18.0"
gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api"
# Native push notifications (iOS/Android)
gem "action_push_native"
# Telemetry
gem "rails_structured_logging", bc: "rails-structured-logging"
gem "sentry-ruby"
+122 -62
View File
@@ -23,9 +23,9 @@ GIT
GIT
remote: https://github.com/basecamp/lexxy
revision: 4bcf8d0213971560e90734493f8bd835addc3642
revision: b7f0f1429df5fa425eac043191ae2b65edee7488
specs:
lexxy (0.7.4.beta)
lexxy (0.7.6.beta)
rails (>= 8.0.2)
GIT
@@ -39,9 +39,9 @@ GIT
GIT
remote: https://github.com/basecamp/rails-structured-logging
revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6
revision: f6633fd8fee5906d8affa2329c61de7a3450eb7e
specs:
rails_structured_logging (0.2.1)
rails_structured_logging (0.3.0)
json
rails (>= 6.0.0)
@@ -62,7 +62,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 60d92e4e7dfe923528ccdccc18820ccfe841b7b8
revision: 12e24eaf2f0a9613e015653f013dd131317d9bf5
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -96,7 +96,7 @@ GIT
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.2.0.alpha)
action_text-trix (~> 2.1.15)
action_text-trix (~> 2.1.16)
actionpack (= 8.2.0.alpha)
activerecord (= 8.2.0.alpha)
activestorage (= 8.2.0.alpha)
@@ -162,6 +162,15 @@ GIT
tsort (>= 0.2)
zeitwerk (~> 2.6)
GIT
remote: https://github.com/rails/web-console.git
revision: 90e3474306f2367cfeaf2875e91e2bc2d71b5f0f
specs:
web-console (4.3.0)
actionview (>= 8.0.0)
bindex (>= 0.4.0)
railties (>= 8.0.0)
PATH
remote: saas
specs:
@@ -186,8 +195,18 @@ PATH
GEM
remote: https://rubygems.org/
specs:
action_push_native (0.3.1)
activejob (>= 8.0)
activerecord (>= 8.0)
googleauth (~> 1.14)
httpx (~> 1.6)
jwt (>= 2)
railties (>= 8.0)
action_text-trix (2.1.16)
railties
actionpack-xml_parser (2.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
activemodel-serializers-xml (1.0.3)
activemodel (>= 5.0.0.a)
activesupport (>= 5.0.0.a)
@@ -196,15 +215,15 @@ GEM
activemodel (>= 7.0)
activemodel-serializers-xml (~> 1.0)
activesupport (>= 7.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
anyway_config (2.7.2)
ruby-next-core (~> 1.0)
ast (2.4.3)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1203.0)
aws-sdk-core (3.241.3)
aws-partitions (1.1218.0)
aws-sdk-core (3.242.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -212,11 +231,11 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.120.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-kms (1.122.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.211.0)
aws-sdk-core (~> 3, >= 3.241.3)
aws-sdk-s3 (1.213.0)
aws-sdk-core (~> 3, >= 3.241.4)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -227,9 +246,9 @@ GEM
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.22.0)
bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (8.0.1)
brakeman (8.0.2)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -261,17 +280,24 @@ GEM
drb (2.2.3)
dry-initializer (3.2.0)
ed25519 (1.4.0)
erb (6.0.1)
erb (6.0.2)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
faker (3.6.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
ffi (1.17.2-arm-linux-musl)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.17.2-x86_64-linux-musl)
fugit (1.12.1)
@@ -282,27 +308,43 @@ GEM
addressable (>= 2.5.0)
globalid (1.3.0)
activesupport (>= 6.1)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-logging-utils (0.2.0)
googleauth (1.16.1)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
gvltools (0.4.0)
hashdiff (1.2.1)
http-2 (1.1.1)
httpx (1.7.1)
http-2 (>= 1.0.0)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.2.2)
importmap-rails (2.2.3)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.16.0)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
jmespath (1.6.2)
json (2.18.0)
json (2.18.1)
jwt (3.1.2)
base64
kamal (2.10.1)
@@ -339,7 +381,8 @@ GEM
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
minitest (6.0.1)
minitest (6.0.2)
drb (~> 2.0)
prism (~> 1.5)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
@@ -352,12 +395,15 @@ GEM
stimulus-rails
turbo-rails
mittens (0.3.1)
mocha (3.0.1)
mocha (3.0.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-http-persistent (4.0.8)
connection_pool (>= 2.2.4, < 4)
net-imap (0.6.2)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -372,21 +418,24 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.5)
nokogiri (1.19.0-aarch64-linux-gnu)
nokogiri (1.19.1-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-aarch64-linux-musl)
nokogiri (1.19.1-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.0-arm-linux-gnu)
nokogiri (1.19.1-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.0-arm-linux-musl)
nokogiri (1.19.1-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.19.0-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.0-x86_64-linux-musl)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4)
openssl (4.0.0)
os (1.1.4)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
@@ -398,7 +447,7 @@ GEM
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.8.0)
prism (1.9.0)
prometheus-client-mmap (1.4.0)
base64
bigdecimal
@@ -419,6 +468,11 @@ GEM
bigdecimal
logger
rb_sys (~> 0.9.117)
prometheus-client-mmap (1.4.0-x86_64-darwin)
base64
bigdecimal
logger
rb_sys (~> 0.9.117)
prometheus-client-mmap (1.4.0-x86_64-linux-gnu)
base64
bigdecimal
@@ -436,12 +490,12 @@ GEM
psych (5.3.1)
date
stringio
public_suffix (6.0.2)
puma (7.1.0)
public_suffix (7.0.2)
puma (7.2.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.4)
rack (3.2.5)
rack-mini-profiler (4.0.1)
rack (>= 1.2.0)
rack-session (2.1.1)
@@ -455,15 +509,15 @@ GEM
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
rails-html-sanitizer (1.7.0)
loofah (~> 2.25)
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)
rake-compiler-dock (1.9.1)
rb_sys (0.9.117)
rake-compiler-dock (= 1.9.1)
rdoc (7.0.3)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
@@ -513,7 +567,7 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.40.0)
selenium-webdriver (4.41.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
@@ -525,6 +579,11 @@ GEM
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
sniffer (0.5.0)
anyway_config (>= 1.0)
dry-initializer (~> 3)
@@ -537,20 +596,21 @@ GEM
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.2.4)
solid_queue (1.3.2)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.8.0-aarch64-linux-gnu)
sqlite3 (2.8.0-aarch64-linux-musl)
sqlite3 (2.8.0-arm-linux-gnu)
sqlite3 (2.8.0-arm-linux-musl)
sqlite3 (2.8.0-arm64-darwin)
sqlite3 (2.8.0-x86_64-linux-gnu)
sqlite3 (2.8.0-x86_64-linux-musl)
sqlite3 (2.9.0-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin)
sqlite3 (2.9.0-x86_64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl)
sshkit (1.25.0)
base64
logger
@@ -558,20 +618,22 @@ GEM
net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0)
ostruct
stackprof (0.2.27)
stackprof (0.2.28)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
stripe (18.0.1)
thor (1.5.0)
thruster (0.1.17)
thruster (0.1.17-aarch64-linux)
thruster (0.1.17-arm64-darwin)
thruster (0.1.17-x86_64-linux)
thruster (0.1.18)
thruster (0.1.18-aarch64-linux)
thruster (0.1.18-arm64-darwin)
thruster (0.1.18-x86_64-darwin)
thruster (0.1.18-x86_64-linux)
timeout (0.6.0)
trilogy (2.9.0)
trilogy (2.10.0)
bigdecimal
tsort (0.2.0)
turbo-rails (2.0.21)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -581,11 +643,6 @@ GEM
unicode-emoji (4.1.0)
uri (1.1.1)
vcr (6.4.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
web-push (3.1.0)
jwt (~> 3.0)
openssl (>= 3.0)
@@ -628,7 +685,7 @@ GEM
anyway_config (>= 1.3, < 3)
railties
yabeda (~> 0.8)
zeitwerk (2.7.4)
zeitwerk (2.7.5)
zip_kit (6.3.4)
PLATFORMS
@@ -638,11 +695,14 @@ PLATFORMS
arm-linux-gnu
arm-linux-musl
arm64-darwin
x86_64-darwin
x86_64-linux
x86_64-linux-gnu
x86_64-linux-musl
DEPENDENCIES
action_push_native
actionpack-xml_parser
activeresource
audits1984!
autotuner
@@ -685,17 +745,17 @@ DEPENDENCIES
sentry-ruby
solid_cable (>= 3.0)
solid_cache (~> 1.0)
solid_queue (~> 1.2)
solid_queue (~> 1.3)
sqlite3 (>= 2.0)
stackprof
stimulus-rails
stripe (~> 18.0)
thruster
trilogy (~> 2.9)
trilogy (~> 2.10)
turbo-rails
useragent!
vcr
web-console
web-console!
web-push
webmock
webrick
+1 -1
View File
@@ -1 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="3.25" cy="12" r="3.25"/><circle cx="12" cy="12" r="3.25"/><circle cx="20.75" cy="12" r="3.25"/></svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="3"/><circle cx="4" cy="12" r="3"/><circle cx="20" cy="12" r="3"/></svg>

Before

Width:  |  Height:  |  Size: 173 B

After

Width:  |  Height:  |  Size: 158 B

+24 -1
View File
@@ -18,6 +18,7 @@
padding: var(--btn-padding, 0.5em 1.1em);
pointer-events: auto;
position: relative;
text-decoration: none;
transition: 100ms ease-out;
transition-property: background-color, border, box-shadow, color, opacity, scale;
@@ -127,7 +128,7 @@
}
@media (min-width: 640px) {
.btn--circle-mobile .icon--mobile-only {
.btn .icon--mobile-only {
display: none !important;
}
}
@@ -271,4 +272,26 @@
inline-size: 100%;
}
}
/* Button utilities
/* ------------------------------------------------------------------------ */
:is([data-platform~=mobile], [data-platform~=native]) {
.btn--ensure-tap-target-size {
--tap-target-z-index: 1;
--tap-target-min-size: 44px;
z-index: var(--tap-target-z-index);
&::before {
content: "";
display: block;
block-size: var(--tap-target-min-size);
inline-size: var(--tap-target-min-size);
inset: calc(50% - var(--tap-target-min-size) / 2) auto auto calc(50% - var(--tap-target-min-size) / 2);
opacity: 0;
position: absolute;
}
}
}
}
+9 -1
View File
@@ -286,6 +286,7 @@
inset-inline-end: 0;
position: absolute;
translate: 100%;
z-index: 2;
}
@media (min-width: 640px) {
@@ -769,13 +770,16 @@
--panel-padding: 2ch;
--panel-size: 100%;
display: flex;
flex-wrap: wrap;
gap: var(--gap);
inset-block-start: 0;
justify-content: center;
position: absolute;
z-index: 1;
&[open] {
display: flex;
}
}
/* On Deck (Not Now)
@@ -819,6 +823,10 @@
position: absolute;
translate: 20% -20%;
z-index: 1;
@media (max-width: 639px) {
translate: 20% 0%;
}
}
/* Maybe column: position bubble relative to the title, not the container */
+20 -23
View File
@@ -36,20 +36,10 @@
@media (max-width: 639px) {
&:has(.card__closed) {
padding-block-start: 2rem;
}
padding-block-start: 4.5rem;
&:has(.reactions .reaction) {
.card__meta-avatars--assignees {
margin-inline-end: unset;
}
}
&:not(:has(.reactions .reaction)) {
&:has(.card__closed) {
.card__meta-text--assignees {
margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block));
}
.card-perma__bg {
box-shadow: 0 -2rem 0 0 var(--color-container);
}
}
}
@@ -110,6 +100,7 @@
@media (max-width: 639px) {
flex-direction: column;
padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5);
position: static;
}
}
@@ -186,7 +177,7 @@
}
.card__meta-avatars--assignees {
margin-inline: 0 calc(var(--btn-size) + var(--meta-spacer-block));
margin-inline: 0;
margin-block-start: var(--meta-spacer-block);
}
@@ -232,6 +223,14 @@
}
}
.card__closed {
@media (max-width: 639px) {
inset: auto 0 100% auto;
translate: 0 30%;
scale: 75%;
}
}
.card__footer {
--btn-size: 2.5rem;
@@ -251,8 +250,12 @@
}
@media (max-width: 639px) {
display: grid;
font-size: var(--text-x-small);
position: relative;
grid-template-columns: 1fr auto;
grid-template-areas:
"meta bg-zoom"
"meta reactions";
}
}
@@ -286,17 +289,11 @@
}
&:not(:has(.reaction)) {
margin: 0;
.reactions__trigger {
--btn-border-color: var(--color-ink-light);
}
@media (max-width: 639px) {
inset-inline-end: 0;
}
@media (min-width: 640px) {
position: static;
}
}
}
+5 -6
View File
@@ -207,6 +207,10 @@
line-height: 1.15;
text-wrap: balance;
&.overflow-line-clamp {
text-wrap: unset; /* text-wrap: balance breaks -webkit-line-clamp in Safari */
}
.card-field__title {
overflow: hidden; /* prevent scrolling on windows */
padding-block: var(--autosize-block-padding);
@@ -398,6 +402,7 @@
position: absolute;
rotate: 5deg;
transform-origin: top right;
z-index: 2;
.cards & {
display: none;
@@ -409,12 +414,6 @@
}
}
}
@media (max-width: 639px) {
inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto;
translate: 0 40%;
scale: 75%;
}
}
.card:has(.card__closed),
+11
View File
@@ -84,6 +84,17 @@
.reactions {
margin-block-start: var(--block-space-half);
margin-inline: calc(var(--column-gap) / -1);
&:not(:has(.reaction)) {
inset-block-end: var(--comment-padding-block);
inset-inline-end: calc(var(--comment-padding-inline) / 2);
margin: 0;
position: absolute;
@media (max-width: 640px) {
inset-inline-end: calc(var(--comment-padding-inline) / 3);
}
}
}
}
+3 -1
View File
@@ -1,7 +1,7 @@
@layer components {
.flash {
display: flex;
inset-block-start: var(--block-space);
inset-block-start: calc(var(--block-space) + var(--custom-safe-inset-top));
inset-inline-start: 50%;
justify-content: center;
position: fixed;
@@ -16,7 +16,9 @@
color: var(--flash-color, var(--color-ink-inverted));
display: inline-flex;
font-size: var(--font-size-medium);
inline-size: max-content;
margin: 0 auto;
max-inline-size: 90vw;
padding: 0.7em 1.4em;
}
}
+9 -5
View File
@@ -12,10 +12,10 @@
--lexxy-color-canvas: var(--color-canvas);
--lexxy-color-accent-dark: var(--color-accent-dark);
--lexxy-color-accent-medium: var(--color-accent-medium);
--lexxy-color-accent-light: var(--color-accent-light);
--lexxy-color-accent-lightest: var(--color-accent-lightest);
--lexxy-color-accent-dark: var(--color-ink-dark);
--lexxy-color-accent-medium: var(--color-ink-medium);
--lexxy-color-accent-light: var(--color-ink-light);
--lexxy-color-accent-lightest: var(--color-ink-lighter);
--lexxy-color-red: oklch(var(--lch-red-medium));
--lexxy-color-green: oklch(var(--lch-green-medium));
@@ -38,6 +38,8 @@
--lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected);
--lexxy-color-table-cell-toggle: var(--lexxy-color-selected);
--lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%);
--lexxy-focus-ring-offset: 2px;
}
@layer components {
@@ -139,7 +141,9 @@
}
ol, ul {
padding-inline-start: 1ch;
&:not(.lexxy-prompt-menu) {
padding-inline-start: 1ch;
}
}
blockquote {
+30 -1
View File
@@ -20,6 +20,16 @@
/* Header
/* ------------------------------------------------------------------------ */
.header {
padding-block-start: var(--custom-safe-inset-top);
}
.header--mobile-actions-stack {
.header__title {
margin-block-start: 0;
}
}
.header:is(
:not(:has(.header__title, .header__actions)),
:not(:has(.header__title, .header__actions--end)):has(.header__actions--start .btn--back:only-child)
@@ -68,13 +78,32 @@
.card-perma--draft {
.card-perma__bg {
box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container);
padding-inline: 0;
padding-block-start: 0;
background-color: unset;
}
.card {
background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color));
box-shadow: 0 101vh 0 100vh var(--card-bg-color);
}
.card__board {
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
.card-perma__notch--bottom {
z-index: 1;
}
}
/* Search
/* ------------------------------------------------------------------------ */
.search {
overscroll-behavior: auto;
}
.search__title {
text-decoration: none;
}
+3 -3
View File
@@ -69,11 +69,11 @@
0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%),
0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%),
0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);
grid-template-rows: auto 1fr auto;
gap: var(--nav-section-gap);
grid-template-rows: auto 1fr auto;
inset: 0 0 auto 0;
margin-inline: auto;
max-block-size: calc(100vh - var(--block-space) - var(--footer-height));
max-block-size: calc(100dvh - var(--block-space) - var(--footer-height));
overflow: hidden;
padding-block-end: 0;
scrollbar-gutter: stable both-edges;
@@ -82,7 +82,7 @@
z-index: var(--z-nav);
@media (max-height: 668px) {
max-block-size: calc(100vh - var(--block-space));
max-block-size: calc(100dvh - var(--block-space));
}
}
+1 -2
View File
@@ -232,8 +232,7 @@
/* Settings
/* ------------------------------------------------------------------------ */
.settings__user-filter .input,
.settings__user-list-tips {
.settings__user-filter .input {
display: none;
}
+1 -8
View File
@@ -10,6 +10,7 @@
flex-wrap: wrap;
gap: var(--inline-space-half);
inline-size: 100%;
z-index: 3;
&:has([open]) {
z-index: var(--z-popup);
@@ -17,14 +18,6 @@
&:not(:has(.reaction)) {
inline-size: auto;
inset-block-end: var(--comment-padding-block);
inset-inline-end: calc(var(--comment-padding-inline) / 2);
margin: 0;
position: absolute;
@media (max-width: 640px) {
inset-inline-end: calc(var(--comment-padding-inline) / 3);
}
.reactions__list {
display: none;
+1 -1
View File
@@ -117,7 +117,7 @@ summary {
.search-perma {
.search__form > label,
.search__reset {
.search__form:has(.search__input:placeholder-shown) .search__reset {
display: none;
}
+28 -32
View File
@@ -3,13 +3,20 @@
--settings-spacer: var(--block-space);
--settings-item-padding-inline: 0.5ch;
display: grid;
align-items: start;
display: flex;
gap: calc(var(--settings-spacer) * 2);
flex-direction: column;
justify-content: center;
margin: 0 auto;
max-inline-size: min(100ch, 100%);
@media (min-width: 960px) {
grid-template-columns: repeat(2, 1fr);
flex-direction: row;
.settings__panel {
max-inline-size: 50%;
}
}
}
@@ -55,10 +62,7 @@
}
}
/* Users
/* ------------------------------------------------------------------------ */
.settings__section--users {
.settings__section:has(.settings__scrollable-list) {
@media (min-width: 640px) {
display: flex;
flex: 1;
@@ -66,24 +70,8 @@
min-height: 0;
}
}
.settings__user-filter {
--btn-size: 3.5ch;
--avatar-size: var(--btn-size);
display: flex;
flex-direction: column;
gap: calc(var(--settings-spacer) / 2);
min-height: 0;
}
.settings__user-filter--bg {
background-color: var(--color-ink-lightest);
border-radius: 0.5em;
margin-block: 0;
padding: var(--settings-spacer);
}
.settings__user-list {
.settings__scrollable-list {
flex: 1 1 auto;
list-style: none;
margin: calc(var(--settings-spacer) / -4) calc(-1 * var(--settings-item-padding-inline));
@@ -116,15 +104,23 @@
}
}
.settings__user-list-tips {
border-top: 1px solid var(--color-ink-light);
color: var(--color-ink-dark);
font-size: var(--text-small);
padding-block-start: var(--settings-spacer);
/* Users
/* ------------------------------------------------------------------------ */
.settings__user-filter--bg & {
margin-inline: calc(-1 * var(--settings-spacer));
padding-inline: var(--settings-spacer);
}
.settings__user-filter {
--btn-size: 3.5ch;
--avatar-size: var(--btn-size);
display: flex;
flex-direction: column;
gap: calc(var(--settings-spacer) / 2);
min-height: 0;
}
.settings__user-filter--bg {
background-color: var(--color-ink-lightest);
border-radius: 0.5em;
margin-block: 0;
padding: var(--settings-spacer);
}
}
+2 -1
View File
@@ -5,7 +5,8 @@ class BoardsController < ApplicationController
before_action :ensure_permission_to_admin_board, only: %i[ update destroy ]
def index
set_page_and_extract_portion_from Current.user.boards
set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed.includes(creator: :identity)
fresh_when etag: @page.records
end
def show
@@ -0,0 +1,15 @@
class ClientConfigurationsController < ApplicationController
skip_before_action :require_account, :require_authentication
allow_unauthorized_access
def show
expires_in 1.minute, public: true
render action: client_configuration_name
end
private
def client_configuration_name
"#{params.require(:platform)}_v#{params.require(:version)}"
end
end
@@ -7,14 +7,10 @@ module RequestForgeryProtection
private
def verified_via_header_only?
super || allowed_api_request? || allowed_insecure_context_request?
super || allowed_api_request?
end
def allowed_api_request?
sec_fetch_site_value.nil? && request.format.json?
end
def allowed_insecure_context_request?
sec_fetch_site_value.nil? && !request.ssl? && !Rails.configuration.force_ssl
end
end
@@ -1,9 +1,27 @@
class Notifications::TraysController < ApplicationController
MAX_ENTRIES_LIMIT = 100
def show
@notifications = Current.user.notifications.unread.preloaded.ordered.limit(100)
@notifications = unread_notifications
if include_read?
@notifications += read_notifications
end
# Invalidate on the whole set instead of the unread set since the max updated at in the unread set
# can stay the same when reading old notifications.
fresh_when Current.user.notifications
fresh_when etag: [ Current.user.notifications, include_read? ]
end
private
def unread_notifications
Current.user.notifications.unread.preloaded.ordered.limit(MAX_ENTRIES_LIMIT)
end
def read_notifications
Current.user.notifications.read.preloaded.ordered.limit(MAX_ENTRIES_LIMIT)
end
def include_read?
ActiveModel::Type::Boolean.new.cast(params[:include_read])
end
end
+4 -2
View File
@@ -2,10 +2,12 @@ class SearchesController < ApplicationController
include Turbo::DriveHelper
def show
if card = Current.user.accessible_cards.find_by_id(params[:q])
@query = params[:q].blank? ? nil : params[:q]
if card = Current.user.accessible_cards.find_by_id(@query)
@card = card
else
set_page_and_extract_portion_from Current.user.search(params[:q])
set_page_and_extract_portion_from Current.user.search(@query)
@recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
end
end
@@ -29,7 +29,7 @@ class Signups::CompletionsController < ApplicationController
redirect_to landing_url(script_name: @signup.account.slug)
end
format.json { render json: { account_id: @signup.account.id }, status: :created }
format.json { head :created }
end
end
+1 -1
View File
@@ -3,7 +3,7 @@ class UsersController < ApplicationController
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
def index
set_page_and_extract_portion_from Current.account.users.active.alphabetically
set_page_and_extract_portion_from Current.account.users.active.alphabetically.includes(:identity)
end
def show
+1 -8
View File
@@ -1,13 +1,6 @@
require "zlib"
module AvatarsHelper
AVATAR_COLORS = %w[
#AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
#736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
]
def avatar_background_color(user)
AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size]
user.avatar_background_color
end
def avatar_tag(user, hidden_for_screen_reader: false, **options)
-8
View File
@@ -9,12 +9,4 @@ module BoardsHelper
icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader")
end
end
def bridged_button_to_board(board)
link_to "Go to #{board.name}", board, hidden: true, data: {
bridge__buttons_target: "button",
bridge_icon_url: bridge_icon("board"),
bridge_title: "Go to #{board.name}"
}
end
end
+32
View File
@@ -2,4 +2,36 @@ module BridgeHelper
def bridge_icon(name)
asset_url("#{name}.svg")
end
def bridged_button_to_board(board)
link_to "Go to #{board.name}", board, hidden: true, data: {
bridge__buttons_target: "button",
bridge_icon_url: bridge_icon("board"),
bridge_title: "Go to #{board.name}"
}
end
def bridged_share_url_button(description = nil)
tag.button "Share", hidden: true, data: {
controller: "bridge--share",
action: "bridge--share#shareUrl",
bridge__overflow_menu_target: "item",
bridge_title: "Share",
bridge_share_description: description
}
end
def bridge_share_card_description(card)
date_added = card.created_at.strftime("%b %e")
date_updated = card.last_active_at.strftime("%b %e")
author = card.creator.familiar_name
assignees = card.assignees.any? ? "assigned to #{card.assignees.map { |assignee| h assignee.familiar_name }.to_sentence}" : "not assigned"
"Added #{date_added} by #{author} and #{assignees}. Updated #{date_updated}"
end
def bridge_share_board_description(board)
count_open = board.cards.active.count
count_in_stream = board.cards.awaiting_triage.count
"#{count_open} open cards, #{count_in_stream} in MAYBE?"
end
end
+1
View File
@@ -11,6 +11,7 @@ module My::MenuHelper
aria: { activedescendant: "" },
data: {
"1p-ignore": "true",
dialog_target: "focusMouse",
filter_target: "input",
nav_section_expander_target: "input",
navigable_list_target: "input",
@@ -22,15 +22,6 @@ export default class extends Controller {
this.#hideItem(this.searchTarget)
}
clearInput() {
if (this.searchInputTarget.value) {
this.searchInputTarget.value = ""
this.searchInputTarget.focus()
} else {
this.reset()
}
}
showModalAndSubmit(event) {
this.showModal()
this.formTarget.requestSubmit()
@@ -5,6 +5,26 @@ export default class extends BridgeComponent {
static component = "buttons"
static targets = [ "button" ]
connect() {
super.connect()
if (!this.beforeUnloadHandler) {
this.beforeUnloadHandler = this.handleBeforeUnload.bind(this)
}
window.addEventListener("beforeunload", this.beforeUnloadHandler)
}
disconnect() {
super.disconnect()
if (this.beforeUnloadHandler) {
window.removeEventListener("beforeunload", this.beforeUnloadHandler)
}
this.notifyBridgeOfDisconnect()
}
buttonTargetConnected() {
this.notifyBridgeOfConnect()
}
@@ -27,6 +47,14 @@ export default class extends BridgeComponent {
})
}
notifyBridgeOfDisconnect() {
this.send("disconnect")
}
handleBeforeUnload() {
this.notifyBridgeOfDisconnect()
}
#clickButton(message) {
const selectedIndex = message.data.selectedIndex
this.#enabledButtonTargets[selectedIndex].click()
@@ -6,13 +6,31 @@ export default class extends BridgeComponent {
static targets = [ "submit", "cancel" ]
static values = { submitTitle: String }
connect() {
super.connect()
if (!this.beforeUnloadHandler) {
this.beforeUnloadHandler = this.handleBeforeUnload.bind(this)
}
window.addEventListener("beforeunload", this.beforeUnloadHandler)
}
disconnect() {
super.disconnect()
if (this.beforeUnloadHandler) {
window.removeEventListener("beforeunload", this.beforeUnloadHandler)
}
}
submitTargetConnected() {
this.notifyBridgeOfConnect()
this.#observeSubmitTarget()
}
submitTargetDisconnected() {
this.notifyBridgeOfDisonnect()
this.notifyBridgeOfDisconnect()
this.submitObserver?.disconnect()
}
@@ -37,7 +55,7 @@ export default class extends BridgeComponent {
}
}
notifyBridgeOfDisonnect() {
notifyBridgeOfDisconnect() {
this.send("disconnect")
}
@@ -49,6 +67,10 @@ export default class extends BridgeComponent {
this.send("submitEnd")
}
handleBeforeUnload() {
this.notifyBridgeOfDisconnect()
}
#observeSubmitTarget() {
this.submitObserver = new MutationObserver(() => {
this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled")
@@ -0,0 +1,14 @@
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
static component = "share"
shareUrl() {
const description = this.bridgeElement.bridgeAttribute("share-description")
this.send("shareUrl", {
title: document.title,
url: window.location.href,
description: description
})
}
}
@@ -0,0 +1,14 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["searchInput"]
clearInput() {
if (this.searchInputTarget.value) {
this.searchInputTarget.value = ""
this.searchInputTarget.focus()
} else {
this.dispatch("reset")
}
}
}
@@ -3,7 +3,9 @@ import { BridgeElement } from "@hotwired/hotwire-native-bridge"
BridgeElement.prototype.getButton = function() {
return {
title: this.title,
icon: this.getIcon()
icon: this.getIcon(),
displayTitle: this.getDisplayTitle(),
displayAsPrimaryAction: this.getDisplayAsPrimaryAction()
}
}
@@ -16,3 +18,11 @@ BridgeElement.prototype.getIcon = function() {
return null
}
BridgeElement.prototype.getDisplayTitle = function() {
return !!this.bridgeAttribute(`display-title`)
}
BridgeElement.prototype.getDisplayAsPrimaryAction = function() {
return !!this.bridgeAttribute(`display-as-primary-action`)
}
+5
View File
@@ -0,0 +1,5 @@
class Notification::PushJob < ApplicationJob
def perform(notification)
notification.push
end
end
+1 -1
View File
@@ -2,6 +2,6 @@ class PushNotificationJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(notification)
NotificationPusher.new(notification).push
notification.push
end
end
@@ -31,7 +31,7 @@ class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet
account_data = load(files.first)
join_code_data = account_data.delete("join_code")
account.update!(name: account_data.fetch("name"))
account.update!(name: account_data.fetch("name"), cards_count: account_data.fetch("cards_count", 0))
account.join_code.update!(join_code_data.slice("usage_count", "usage_limit"))
account.join_code.update(code: join_code_data.fetch("code"))
end
@@ -1,4 +1,4 @@
class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer::RecordSet
class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransfer::RecordSet
ATTRIBUTES = %w[
account_id
body
@@ -11,16 +11,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
].freeze
def initialize(account)
super(account: account, model: ActionText::RichText)
super(account: account, model: ::ActionText::RichText)
end
private
def records
ActionText::RichText.where(account: account)
::ActionText::RichText.where(account: account)
end
def export_record(rich_text)
data = rich_text.as_json.merge("body" => convert_sgids_to_gids(rich_text.body))
data = rich_text.as_json.merge("body" => transform_body_for_export(rich_text.body))
zip.add_file "data/action_text_rich_texts/#{rich_text.id}.json", data.to_json
end
@@ -31,11 +31,11 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
def import_batch(files)
batch_data = files.map do |file|
data = load(file)
data["body"] = convert_gids_to_sgids(data["body"])
data["body"] = transform_body_for_import(data["body"])
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
end
ActionText::RichText.insert_all!(batch_data)
::ActionText::RichText.insert_all!(batch_data)
end
def check_record(file_path)
@@ -54,11 +54,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
check_associations_dont_exist(data)
end
def convert_sgids_to_gids(content)
def transform_body_for_export(content)
return nil if content.blank?
html = convert_sgids_to_gids(content)
relativize_urls(html)
end
def convert_sgids_to_gids(content)
content.send(:attachment_nodes).each do |node|
sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME)
sgid = SignedGlobalID.parse(node["sgid"], for: ::ActionText::Attachable::LOCATOR_NAME)
record = begin
sgid&.find
@@ -75,11 +80,35 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
content.fragment.source.to_html
end
def convert_gids_to_sgids(html)
return html if html.blank?
def relativize_urls(html)
host = Rails.application.routes.default_url_options[:host]
return html unless host
fragment = Nokogiri::HTML.fragment(html)
fragment.css("a[href]").each do |link|
uri = URI.parse(link["href"]) rescue nil
if uri.respond_to?(:host) && uri.host == host
link["href"] = uri.path
link["href"] += "?#{uri.query}" if uri.query
link["href"] += "##{uri.fragment}" if uri.fragment
end
end
fragment.to_html
end
def transform_body_for_import(body)
return body if body.blank?
Nokogiri::HTML.fragment(body)
.then { convert_gids_to_sgids(it) }
.then { replace_account_slugs(it) }
.to_html
end
def convert_gids_to_sgids(fragment)
fragment.css("action-text-attachment[gid]").each do |node|
gid = GlobalID.parse(node["gid"])
@@ -97,6 +126,20 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
end
end
fragment.to_html
fragment
end
def replace_account_slugs(fragment)
fragment.css("a[href]").each do |link|
match = link["href"].match(AccountSlug::PATH_INFO_MATCH)
if match
path = match.post_match.presence || "/"
valid_path = Rails.application.routes.recognize_path(path) rescue nil
link["href"] = "#{account.slug}#{path}" if valid_path
end
end
fragment
end
end
@@ -1,9 +1,9 @@
class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer::RecordSet
class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfer::RecordSet
def initialize(account)
super(
account: account,
model: ActiveStorage::Blob,
attributes: ActiveStorage::Blob.column_names - %w[service_name]
model: ::ActiveStorage::Blob,
attributes: ::ActiveStorage::Blob.column_names - %w[service_name]
)
end
@@ -13,7 +13,7 @@ class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer:
data = load(file)
data.slice(*attributes).merge(
"account_id" => account.id,
"service_name" => ActiveStorage::Blob.service.name
"service_name" => ::ActiveStorage::Blob.service.name
)
end
@@ -1,11 +1,11 @@
class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSet
class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfer::RecordSet
def initialize(account)
super(account: account, model: ActiveStorage::Blob)
super(account: account, model: ::ActiveStorage::Blob)
end
private
def records
ActiveStorage::Blob.where(account: account)
::ActiveStorage::Blob.where(account: account)
end
def export_record(blob)
@@ -23,7 +23,7 @@ class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSe
def import_batch(files)
files.each do |file|
key = File.basename(file)
blob = ActiveStorage::Blob.find_by(key: key, account: account)
blob = ::ActiveStorage::Blob.find_by(key: key, account: account)
next unless blob
zip.read(file) do |stream|
@@ -35,7 +35,7 @@ class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSe
def check_record(file_path)
key = File.basename(file_path)
unless zip.exists?("data/active_storage_blobs/#{key}.json") || ActiveStorage::Blob.exists?(key: key, account: account)
unless zip.exists?("data/active_storage_blobs/#{key}.json") || ::ActiveStorage::Blob.exists?(key: key, account: account)
# File exists without corresponding blob record - could be orphaned or blob not yet imported
# We allow this since blob metadata is imported before files
end
+3 -3
View File
@@ -35,10 +35,10 @@ class Account::DataTransfer::Manifest
::Filter, ::Webhook::DelinquencyTracker, ::Event,
::Notification, ::Notification::Bundle, ::Webhook::Delivery
),
Account::DataTransfer::ActiveStorageBlobRecordSet.new(account),
Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account),
*build_record_sets(::ActiveStorage::Attachment),
Account::DataTransfer::ActionTextRichTextRecordSet.new(account),
Account::DataTransfer::BlobFileRecordSet.new(account)
Account::DataTransfer::ActionText::RichTextRecordSet.new(account),
Account::DataTransfer::ActiveStorage::FileRecordSet.new(account)
]
end
+5
View File
@@ -48,6 +48,7 @@ class Account::Import < ApplicationRecord
end
add_importer_to_all_access_boards
reconcile_cards_count
reconcile_account_storage
mark_completed
@@ -78,6 +79,10 @@ class Account::Import < ApplicationRecord
ImportMailer.failed(self).deliver_later
end
def reconcile_cards_count
account.update_column :cards_count, [ account.cards_count, account.cards.maximum(:number).to_i ].max
end
def add_importer_to_all_access_boards
importer = account.users.find_by!(identity: identity)
+4 -4
View File
@@ -17,11 +17,11 @@ module Mentions
rich_text_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ")
end
private
def scan_mentionees
mentionees_from_attachments & mentionable_users
end
def scan_mentionees
mentionees_from_attachments & mentionable_users
end
private
def mentionees_from_attachments
rich_text_associations.flat_map { send(it.name)&.body&.attachments&.collect { it.attachable } }.compact
end
-13
View File
@@ -1,13 +0,0 @@
module PushNotifiable
extend ActiveSupport::Concern
included do
after_create_commit :push_notification_later
after_update_commit :push_notification_later, if: :source_id_previously_changed?
end
private
def push_notification_later
PushNotificationJob.perform_later(self)
end
end
+4
View File
@@ -28,6 +28,10 @@ class Identity < ApplicationRecord
end
end
def users_with_active_accounts
users.joins(:account).merge(Account.active).includes(:account)
end
private
def deactivate_users
users.find_each(&:deactivate)
+9 -2
View File
@@ -1,5 +1,5 @@
class Notification < ApplicationRecord
include PushNotifiable
include Notification::Pushable
belongs_to :account, default: -> { user.account }
belongs_to :user
@@ -10,7 +10,13 @@ class Notification < ApplicationRecord
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
scope :ordered, -> { order(read_at: :desc, updated_at: :desc) }
scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
scope :preloaded, -> {
preload(
:creator, :account,
card: [ :board, :column, :closure, :not_now ],
source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]
)
}
before_validation :set_card
after_create :bundle
@@ -21,6 +27,7 @@ class Notification < ApplicationRecord
after_destroy_commit -> { broadcast_remove_to user, :notifications }
delegate :notifiable_target, to: :source
delegate :identity, to: :user
class << self
def read_all
@@ -0,0 +1,57 @@
class Notification::DefaultPayload
attr_reader :notification
delegate :card, to: :notification
def initialize(notification)
@notification = notification
end
def to_h
{ title: title, body: body, url: url }
end
def title
"New notification"
end
def body
"You have a new notification"
end
def url
notifications_url
end
def category
"default"
end
def high_priority?
false
end
def base_url
Rails.application.routes.url_helpers.root_url(**url_options.except(:script_name)).chomp("/")
end
def avatar_url
Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options)
end
private
def card_url(card)
Rails.application.routes.url_helpers.card_url(card, **url_options)
end
def notifications_url
Rails.application.routes.url_helpers.notifications_url(**url_options)
end
def url_options
base_options = Rails.application.routes.default_url_options.presence ||
Rails.application.config.action_mailer.default_url_options ||
{}
base_options.merge(script_name: notification.account.slug)
end
end
+107
View File
@@ -0,0 +1,107 @@
class Notification::EventPayload < Notification::DefaultPayload
include ExcerptHelper
def title
case event.action
when "comment_created"
"RE: #{card_title}"
else
card_title
end
end
def body
case event.action
when "comment_created"
format_excerpt(event.eventable.body, length: 200)
when "card_assigned"
"Assigned to you by #{event.creator.name}"
when "card_published"
"Added by #{event.creator.name}"
when "card_closed"
card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}"
when "card_reopened"
"Reopened by #{event.creator.name}"
when "card_triaged"
if column_name.present?
"Moved to #{column_name} by #{event.creator.name}"
else
"Moved by #{event.creator.name}"
end
when "card_sent_back_to_triage"
"Moved back to Maybe? by #{event.creator.name}"
when "card_board_changed", "card_collection_changed"
if new_location_name.present?
"Moved to #{new_location_name} by #{event.creator.name}"
else
"Moved by #{event.creator.name}"
end
when "card_title_changed"
if new_title.present?
"Renamed to #{new_title} by #{event.creator.name}"
else
"Renamed by #{event.creator.name}"
end
when "card_postponed"
"Moved to Not Now by #{event.creator.name}"
when "card_auto_postponed"
"Moved to Not Now due to inactivity"
else
"Updated by #{event.creator.name}"
end
end
def url
case event.action
when "comment_created"
card_url_with_comment_anchor(event.eventable)
else
card_url(card)
end
end
def category
case event.action
when "card_assigned" then "assignment"
when "comment_created" then "comment"
else "card"
end
end
def high_priority?
event.action.card_assigned?
end
private
def event
notification.source
end
def card_title
card.title.presence || "Card #{card.number}"
end
def event_particulars
event.particulars.dig("particulars") || {}
end
def column_name
event_particulars["column"]
end
def new_location_name
event_particulars["new_board"] || event_particulars["new_collection"]
end
def new_title
event_particulars["new_title"]
end
def card_url_with_comment_anchor(comment)
Rails.application.routes.url_helpers.card_url(
comment.card,
anchor: ActionView::RecordIdentifier.dom_id(comment),
**url_options
)
end
end
@@ -0,0 +1,28 @@
class Notification::MentionPayload < Notification::DefaultPayload
include ExcerptHelper
def title
"#{mention.mentioner.first_name} mentioned you"
end
def body
format_excerpt(mention.source.mentionable_content, length: 200)
end
def url
card_url(card)
end
def category
"mention"
end
def high_priority?
true
end
private
def mention
notification.source
end
end
+17
View File
@@ -0,0 +1,17 @@
class Notification::PushTarget
attr_reader :notification
delegate :card, to: :notification
def self.process(notification)
new(notification).process
end
def initialize(notification)
@notification = notification
end
def process
raise NotImplementedError
end
end
@@ -0,0 +1,12 @@
class Notification::PushTarget::Web < Notification::PushTarget
def process
if subscriptions.any?
Rails.configuration.x.web_push_pool.queue(notification.payload.to_h, subscriptions)
end
end
private
def subscriptions
@subscriptions ||= notification.user.push_subscriptions
end
end
+52
View File
@@ -0,0 +1,52 @@
module Notification::Pushable
extend ActiveSupport::Concern
included do
class_attribute :push_targets, default: []
after_save_commit :push_later, if: :source_id_previously_changed?
end
class_methods do
def register_push_target(target)
target = resolve_push_target(target)
push_targets << target unless push_targets.include?(target)
end
private
def resolve_push_target(target)
if target.is_a?(Symbol)
"Notification::PushTarget::#{target.to_s.classify}".constantize
else
target
end
end
end
def push_later
Notification::PushJob.perform_later(self)
end
def push
return unless pushable?
self.class.push_targets.each { |target| push_to(target) }
end
def payload
"Notification::#{payload_type}Payload".constantize.new(self)
end
private
def pushable?
!creator.system? && user.active? && account.active?
end
def push_to(target)
target.process(self)
end
def payload_type
source_type.presence_in(%w[ Event Mention ]) || "Default"
end
end
-124
View File
@@ -1,124 +0,0 @@
class NotificationPusher
include Rails.application.routes.url_helpers
include ExcerptHelper
attr_reader :notification
def initialize(notification)
@notification = notification
end
def push
return unless should_push?
build_payload.tap do |payload|
push_to_user(payload)
end
end
private
def should_push?
notification.user.push_subscriptions.any? &&
!notification.creator.system? &&
notification.user.active? &&
notification.account.active?
end
def build_payload
case notification.source_type
when "Event"
build_event_payload
when "Mention"
build_mention_payload
else
build_default_payload
end
end
def build_event_payload
event = notification.source
card = event.card
base_payload = {
title: card_notification_title(card),
path: card_path(card)
}
case event.action
when "comment_created"
base_payload.merge(
title: "RE: #{base_payload[:title]}",
body: comment_notification_body(event),
path: card_path_with_comment_anchor(event.eventable)
)
when "card_assigned"
base_payload.merge(
body: "Assigned to you by #{event.creator.name}"
)
when "card_published"
base_payload.merge(
body: "Added by #{event.creator.name}"
)
when "card_closed"
base_payload.merge(
body: card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}"
)
when "card_reopened"
base_payload.merge(
body: "Reopened by #{event.creator.name}"
)
else
base_payload.merge(
body: event.creator.name
)
end
end
def build_mention_payload
mention = notification.source
card = mention.card
{
title: "#{mention.mentioner.first_name} mentioned you",
body: format_excerpt(mention.source.mentionable_content, length: 200),
path: card_path(card)
}
end
def build_default_payload
{
title: "New notification",
body: "You have a new notification",
path: notifications_path(script_name: notification.account.slug)
}
end
def push_to_user(payload)
subscriptions = notification.user.push_subscriptions
enqueue_payload_for_delivery(payload, subscriptions)
end
def enqueue_payload_for_delivery(payload, subscriptions)
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
end
def card_notification_title(card)
card.title.presence || "Card #{card.number}"
end
def comment_notification_body(event)
format_excerpt(event.eventable.body, length: 200)
end
def card_path(card)
Rails.application.routes.url_helpers.card_path(card, script_name: notification.account.slug)
end
def card_path_with_comment_anchor(comment)
Rails.application.routes.url_helpers.card_path(
comment.card,
anchor: ActionView::RecordIdentifier.dom_id(comment),
script_name: notification.account.slug
)
end
end
+2 -2
View File
@@ -8,9 +8,9 @@ class Notifier::CardEventNotifier < Notifier
when "card_assigned"
source.assignees.excluding(creator)
when "card_published"
board.watchers.without(creator, *card.mentionees).including(*card.assignees).uniq
board.watchers.without(creator, *card.scan_mentionees).including(*card.assignees).uniq
when "comment_created"
card.watchers.without(creator, *source.eventable.mentionees)
card.watchers.without(creator, *source.eventable.scan_mentionees)
else
board.watchers.without(creator)
end
@@ -3,7 +3,7 @@ class Notifier::CommentEventNotifier < Notifier
private
def recipients
card.watchers.without(creator, *source.eventable.mentionees)
card.watchers.without(creator, *source.eventable.scan_mentionees)
end
def card
+1 -1
View File
@@ -5,7 +5,7 @@ module Search::Stemmer
def stem(value)
if value.present?
value.gsub(/[^\w\s]/, "").split(/\s+/).map { |word| STEMMER.stem(word.downcase) }.join(" ")
value.gsub(/[^\w\s]/, " ").split(/\s+/).map { |word| STEMMER.stem(word.downcase) }.join(" ")
else
value
end
+10
View File
@@ -1,8 +1,14 @@
require "zlib"
module User::Avatar
extend ActiveSupport::Concern
ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze
AVATAR_COLORS = %w[
#AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
#736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
].freeze
included do
has_one_attached :avatar do |attachable|
@@ -22,6 +28,10 @@ module User::Avatar
avatar.variable? ? avatar.variant(:thumb) : avatar
end
def avatar_background_color
AVATAR_COLORS[Zlib.crc32(to_param) % AVATAR_COLORS.size]
end
# Avatars are always publicly accessible
def publicly_accessible?
true
+1
View File
@@ -36,6 +36,7 @@ class Webhook < ApplicationRecord
after_create :create_delinquency_tracker!
normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS }
normalizes :url, with: -> { it.strip }
validates :name, presence: true
validate :validate_url
+1 -1
View File
@@ -2,7 +2,7 @@ class ZipFile::Reader
def initialize(io)
@io = io
@reader = ZipKit::FileReader.read_zip_structure(io: io)
rescue ZipKit::FileReader::InvalidStructure => e
rescue ZipKit::FileReader::ReadError, ZipKit::FileReader::MissingEOCD, ZipKit::FileReader::UnsupportedFeature => e
raise ZipFile::InvalidFileError, e.message
end
@@ -1,26 +1,28 @@
<% if Current.account.cancellable? && Current.user.owner? %>
<header class="margin-block-start-double">
<h2 class="divider txt-large txt-negative">Cancel account</h2>
<p class="margin-none-block">Delete your Fizzy account</p>
</header>
<section class="settings__section">
<header>
<h2 class="divider txt-negative">Cancel account</h2>
<div>Delete your Fizzy account.</div>
</header>
<div data-controller="dialog" data-dialog-modal-value="true">
<button type="button" class="btn btn--negative" data-action="dialog#open">Delete account</button>
<div data-controller="dialog" data-dialog-modal-value="true">
<button type="button" class="btn btn--negative" data-action="dialog#open">Delete account</button>
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog">
<h2 class="margin-none txt-large txt-negative">Delete your account?</h2>
<ul class="txt-align-start">
<li>All users, including you, will lose access</li>
<% if Current.account.try(:active_subscription) %>
<li>Your subscription will be canceled</li>
<% end %>
<li>After 30 days your data will be permanently deleted</li>
</ul>
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog" style="--panel-size: 48ch;">
<h2 class="margin-none txt-large txt-negative">Delete your account?</h2>
<ul class="txt-align-start">
<li>All users, including you, will lose access</li>
<% if Current.account.try(:active_subscription) %>
<li>Your subscription will be canceled</li>
<% end %>
<li>After 30 days, your data will be permanently deleted</li>
</ul>
<div class="flex gap justify-center">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %>
</div>
</dialog>
</div>
<div class="flex gap justify-center">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %>
</div>
</dialog>
</div>
</section>
<% end %>
+2 -2
View File
@@ -1,4 +1,4 @@
<section class="settings__section settings__section--users">
<section class="settings__section">
<header>
<h2 class="divider">People on this account</h2>
</header>
@@ -13,7 +13,7 @@
<div class="settings__user-filter">
<input placeholder="Filter…" class="input input--transparent full-width txt-small" type="search" autocorrect="off" autocomplete="off" data-1p-ignore="true" data-filter-target="input" data-action="input->filter#filter">
<ul class="settings__user-list margin-block-half" data-filter-target="list" role="listbox">
<ul class="settings__scrollable-list margin-block-half" data-filter-target="list" role="listbox">
<%= render partial: "account/settings/user", collection: users %>
</ul>
</div>
+2 -2
View File
@@ -16,13 +16,13 @@
<section class="settings">
<div class="settings__panel settings__panel--users panel shadow">
<%= form_with model: @board, class: "display-contents", data: {
controller: "form boards-form",
controller: "form boards-form bridge--form",
boards_form_self_removal_prompt_message_value: "Are you sure you want to remove yourself from this board? You wont be able to get back in unless someone invites you.",
action: "turbo:submit-start->boards-form#submitWithWarning" } do |form| %>
<%= render "boards/edit/name", form: form, board: @board %>
<%= render "boards/edit/users", board: @board, selected_users: @selected_users, unselected_users: @unselected_users, form: form %>
<button type="submit" id="log_in" class="btn btn--link center txt-normal" <%= "disabled" unless Current.user.can_administer_board?(@board) %>>
<button type="submit" id="log_in" class="btn btn--link center txt-normal" data-bridge--form-target="submit" <%= "disabled" unless Current.user.can_administer_board?(@board) %>>
<span>Save changes</span>
</button>
+1 -1
View File
@@ -33,7 +33,7 @@
</div>
</div>
<ul class="settings__user-list" data-filter-target="list">
<ul class="settings__scrollable-list" data-filter-target="list">
<%= access_toggles_for selected_users, selected: true, disabled: %>
<%= access_toggles_for unselected_users, selected: false, disabled: %>
</ul>
+2
View File
@@ -30,3 +30,5 @@
<%= render "boards/show/columns", page: @page, board: @board %>
<% end %>
<% end %>
<%= bridged_share_url_button(bridge_share_board_description(@board)) %>
+1
View File
@@ -13,6 +13,7 @@
navigable_list_auto_select_value: false,
navigable_list_auto_scroll_value: false,
card_hotkeys_navigable_list_outlet: ".cards__transition-container",
native_prevent_pull_to_refresh: true,
action: "
keydown->navigable-list#navigate
keydown->card-hotkeys#handleKeydown
+1
View File
@@ -8,6 +8,7 @@ json.cache! card do
json.tags card.tags.pluck(:title).sort
json.closed card.closed?
json.postponed card.postponed?
json.golden card.golden?
json.last_active_at card.last_active_at.utc
json.created_at card.created_at.utc
+5 -2
View File
@@ -24,13 +24,16 @@
<%= button_to_set_column @card, column %>
<% end %>
<%= button_to "Done", card_closure_path(@card),
<%= button_to card_closure_path(@card),
class: [ "card__column-name btn", { "card__column-name--current": @card.closed? } ],
style: "--column-color: var(--color-card-complete)",
disabled: @card.closed?,
role: "radio",
aria: { checked: @card.closed? },
data: { scroll_to_target: @card.closed? ? "target" : nil },
form_class: "flex gap-half" %>
form_class: "flex gap-half" do %>
<%= icon_tag "check", class: "icon--mobile-only" %>
Done
<% end %>
</div>
<% end %>
@@ -13,9 +13,9 @@ json.cache! comment do
json.card do
json.id comment.card_id
json.url card_url(comment.card_id)
json.url card_url(comment.card)
end
json.reactions_url card_comment_reactions_url(comment.card_id, comment.id)
json.url card_comment_url(comment.card_id, comment.id)
json.reactions_url card_comment_reactions_url(comment.card, comment)
json.url card_comment_url(comment.card, comment)
end
+1 -1
View File
@@ -4,7 +4,7 @@
</figure>
<div class="comment__content flex-inline flex-column full-width">
<div class="comment__body lexxy-content" data-turbo-permanent>
<div class="comment__body" data-turbo-permanent>
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
data: { controller: "form local-save",
local_save_key_value: "comment-#{card.id}",
+1 -1
View File
@@ -5,7 +5,7 @@
</figure>
<div class="comment__content flex flex-column flex-item-grow full-width">
<div class="comment__body lexxy-content" data-turbo-permanent>
<div class="comment__body" data-turbo-permanent>
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width",
data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>
@@ -5,6 +5,7 @@
<%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %>
<span class="pad-inline-end">Undo</span>
<% end %>
<%= bridged_button_to_board(card.board) %>
</div>
<% else %>
<div class="card-perma__notch card-perma__notch--bottom">
@@ -7,10 +7,12 @@
<% end %>
<%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless",
data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark as Done", bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" },
data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" },
form: { data: { controller: "form" } } do %>
<%= icon_tag "check", class: "icon--mobile-only" %>
<span class="overflow-ellipsis">Mark as Done</span>
<kbd class="txt-x-small hide-on-touch">d</kbd>
<% end %>
<%= bridged_button_to_board(card.board) %>
</div>
+3 -3
View File
@@ -1,12 +1,12 @@
<% if card.image.attached? %>
<%= button_to card_image_path(card), method: :delete, class: "btn",
data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Remove background image" } do %>
<%= button_to card_image_path(card), method: :delete, class: "btn hide-on-native",
data: { controller: "tooltip" } do %>
<%= icon_tag "picture-remove" %>
<span class="for-screen-reader">Remove background image</span>
<% end %>
<% elsif !card.closed? %>
<%= form_with model: card, data: { controller: "form" } do |form| %>
<label class="card-perma__image-btn btn input--file btn--circle-mobile" data-controller="tooltip" data-bridge--overflow-menu-target="item" data-bridge-title="Add background image">
<label class="card-perma__image-btn btn input--file btn--circle-mobile hide-on-native" data-controller="tooltip">
<%= icon_tag "picture-add" %>
<span class="for-screen-reader">Add background image</span>
+1 -1
View File
@@ -1,7 +1,7 @@
<div id="<%= dom_id(card, :tags) %>" class="card__tags">
<div data-controller="dialog"
data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames" <%= "hidden" if card.closed? %>>
<button class="card__tag-picker-button btn card__hide-on-index" style="--btn-background: var(--card-bg-color);"
<button class="card__tag-picker-button btn btn--ensure-tap-target-size card__hide-on-index" style="--btn-background: var(--card-bg-color);"
data-controller="tooltip hotkey" data-action="click->dialog#open:stop keydown.t@document->hotkey#click">
<%= icon_tag "tag" %>
<span class="for-screen-reader">Add a tag</span>
+2 -2
View File
@@ -1,8 +1,8 @@
<div id="<%= dom_id(card, :pin_button) %>">
<% if card.pinned_by? Current.user %>
<%= button_to card_pin_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Un-pin this card" } do %>
<%= icon_tag "pinned" %> <span class="for-screen-reader">Un-pin this card (shift+p)</span>
data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Unpin this card" } do %>
<%= icon_tag "pinned" %> <span class="for-screen-reader">Unpin this card (shift+p)</span>
<% end %>
<% else %>
<%= button_to card_pin_path(card), class: "btn btn--circle-mobile",
+2 -1
View File
@@ -9,7 +9,6 @@
<div class="header__actions header__actions--start">
<%= link_back_to_board(@card.board) %>
</div>
<%= bridged_button_to_board(@card.board) %>
<% end %>
<%= turbo_stream_from @card %>
@@ -23,3 +22,5 @@
<%= button_to_remove_card_image(@card) if @card.image.attached? %>
<% end %>
</div>
<%= bridged_share_url_button(bridge_share_card_description(@card)) %>
@@ -0,0 +1,100 @@
{
"settings": {},
"rules": [
{
"patterns": [
".*"
],
"properties": {
"context": "default",
"presentation": "default",
"query_string_presentation": "replace",
"uri": "hotwire://fragment/web",
"fallback_uri": "hotwire://fragment/web",
"pull_to_refresh_enabled": true
}
},
{
"patterns": [
"/new$",
"/new\\?.+$",
"/edit$",
"/edit\\?.+$",
"/cards/[0-9]+/draft",
"/notifications/settings",
"/account/settings",
"/account/join_code"
],
"properties": {
"context": "modal",
"pull_to_refresh_enabled": false
}
},
{
"patterns": [
"/devices$"
],
"properties": {
"context": "modal",
"pull_to_refresh_enabled": false,
"allow_untenanted_navigation": true
}
},
{
"patterns": [
"/native/login/blank"
],
"properties": {
"uri": "hotwire://fragment/login/blank"
}
},
{
"patterns": [
"/native/login/email"
],
"properties": {
"uri": "hotwire://fragment/login/email"
}
},
{
"patterns": [
"/native/login/magic_code"
],
"properties": {
"uri": "hotwire://fragment/login/magic_code"
}
},
{
"patterns": [
"/native/login/signup_completion"
],
"properties": {
"uri": "hotwire://fragment/login/signup_completion"
}
},
{
"patterns": [
"/native/settings"
],
"properties": {
"uri": "hotwire://fragment/settings"
}
},
{
"patterns": [
"/my/pins"
],
"properties": {
"uri": "hotwire://fragment/pins"
}
},
{
"patterns": [
"/notifications$"
],
"properties": {
"uri": "hotwire://fragment/notifications"
}
}
]
}
@@ -0,0 +1,89 @@
{
"settings": {
},
"rules": [
{
"patterns": [
".*"
],
"properties": {
"context": "default",
"query_string_presentation": "replace",
"pull_to_refresh_enabled": true
}
},
{
"patterns": [
"/new$",
"/new\\?.+$",
"/edit$",
"/edit\\?.+$",
"/accounts$",
"/cards/[0-9]+/draft"
],
"properties": {
"context": "modal",
"pull_to_refresh_enabled": false
}
},
{
"patterns": [
"/native/my_menu$"
],
"properties": {
"context": "modal",
"view_controller": "main_menu"
}
},
{
"patterns": [
"/native/add_account$"
],
"properties": {
"context": "modal",
"view_controller": "login"
}
},
{
"patterns": [
"/native/login$"
],
"properties": {
"view_controller": "login"
}
},
{
"patterns": [
"/my/pins$"
],
"properties": {
"view_controller": "pinned"
}
},
{
"patterns": [
"/notifications/tray$"
],
"properties": {
"view_controller": "notifications"
}
},
{
"patterns": [
"internal/devtools/enable*"
],
"properties": {
"context": "modal",
"view_controller": "dev_settings_launcher"
}
},
{
"patterns": [
"/native/settings$"
],
"properties": {
"view_controller": "settings"
}
}
]
}
@@ -1,7 +1,7 @@
<div class="board-tools card">
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link",
form: { data: { turbo_frame: "_top" } },
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_icon_url: bridge_icon("add") } do %>
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %>
<%= icon_tag "add", class: "show-on-touch" %>
<span>Add a card</span>
<kbd class="hide-on-touch">C</kbd>
@@ -1,7 +1,7 @@
<div class="header__actions header__actions--end">
<div>
<%= link_to new_board_path, class: "btn btn--link btn--circle-mobile",
data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a board", bridge_icon_url: bridge_icon("board") } do %>
data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add board", bridge_display_title: true, bridge_icon_url: bridge_icon("board") } do %>
<%= icon_tag "board" %>
<span class="overflow-ellipsis">Add a board</span>
<kbd class="hide-on-touch">B</kbd>
@@ -1,7 +1,7 @@
<div class="header__actions header__actions--start">
<% if board = user_filtering.single_board_or_first %>
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile",
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a card", bridge_icon_url: bridge_icon("add") } do %>
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %>
<%= icon_tag "add" %>
<span class="overflow-ellipsis">Add a card</span>
<kbd class="hide-on-touch">C</kbd>
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<%= render "layouts/shared/head" %>
<body class="public <%= @body_class %>" data-controller="local-time timezone-cookie" data-action="turbo:morph@window->local-time#refreshAll">
<body class="public <%= @body_class %>" data-controller="local-time timezone-cookie bridge--title bridge--text-size bridge--insets" data-action="turbo:morph@window->local-time#refreshAll" data-platform="<%= platform.type %>" data-bridge--title-title-value="<%= @page_title %>">
<div id="global-container">
<header class="header" id="header">
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
+3 -1
View File
@@ -1,4 +1,6 @@
json.accounts @identity.users do |user|
json.id @identity.id
json.accounts @identity.users_with_active_accounts do |user|
json.partial! "my/identities/account", account: user.account
json.user user, partial: "users/user", as: :user
end
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="flex gap">
<div class="flex flex-column full-width justify-center gap-half">
<div class="nav__header">
<div class="nav__header" tabindex="-1" data-dialog-target="focusTouch">
<% if Current.identity.accounts.many? %>
<div class="nav__header-title max-width">
<div class="overflow-ellipsis"><strong><%= Current.account.name %></strong></div>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="tray__item tray__item--pin" id="<%= dom_id pin %>" data-navigable-list-target="item">
<%= render "cards/display/preview", card: pin.card %>
<%= button_to card_pin_path(pin.card), method: :delete, class: "tray__remove-pin-btn btn btn--circle borderless" do %>
<%= icon_tag "pinned" %> <span class="for-screen-reader">Un-pin this card</span>
<%= icon_tag "pinned" %> <span class="for-screen-reader">Unpin this card</span>
<% end %>
</div>
@@ -3,14 +3,19 @@ json.cache! notification do
json.read notification.read?
json.read_at notification.read_at&.utc
json.created_at notification.created_at.utc
json.source_type notification.source_type.underscore
json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification
json.creator notification.creator, partial: "users/user", as: :user
json.card do
json.(notification.card, :id, :title, :status)
json.(notification.card, :id, :number, :title, :status)
json.board_name notification.card.board.name
json.closed notification.card.closed?
json.postponed notification.card.postponed?
json.url card_url(notification.card)
json.column notification.card.column, partial: "columns/column", as: :column if notification.card.column
end
json.url notification_url(notification)
@@ -8,7 +8,7 @@
<div class="settings__panel settings__panel--users panel shadow">
<section class="settings__section">
<h2 class="divider">Boards</h2>
<div class="settings__user-list flex flex-column gap-half">
<div class="settings__scrollable-list flex flex-column gap-half">
<%= render partial: "notifications/settings/board", collection: @boards, locals: { user: Current.user } %>
</div>
</section>
@@ -16,6 +16,7 @@
<div class="settings__panel panel shadow">
<%= render "notifications/settings/push_notifications" %>
<%= render "notifications/settings/native_devices" if Fizzy.saas? %>
<%= render "notifications/settings/email", settings: @settings %>
</div>
</section>
@@ -0,0 +1 @@
json.array! @notifications, partial: "notifications/notification", as: :notification
+1 -1
View File
@@ -25,7 +25,7 @@ async function updateBadgeCount({ data: { badge } }) {
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = new URL(event.notification.data.path, self.location.origin).href
const url = new URL(event.notification.data.url, self.location.origin).href
event.waitUntil(openURL(url))
})
+10 -2
View File
@@ -1,4 +1,10 @@
<%= form_with url: search_path, method: :get, class: "search__form flex align-center justify-center gap-half", data: { bar_target: "form", turbo_frame: defined?(target_turbo_frame) ? target_turbo_frame : nil } do |form| %>
<%= form_with url: search_path, method: :get,
class: "search__form flex align-center justify-center gap-half",
data: {
controller: "search-form",
action: "search-form:reset->bar#reset",
bar_target: "form",
turbo_frame: defined?(target_turbo_frame) ? target_turbo_frame : nil } do |form| %>
<%= form.label :q, "Search Fizzy", class: "font-weight-black txt-nowrap" %>
<%= text_field_tag :q, query_terms,
class: "search__input input",
@@ -7,9 +13,11 @@
autocomplete: "off",
autofocus: true,
data: {
search_form_target: "searchInput",
bar_target: "searchInput",
action: "keydown.enter->bar#showModalAndSubmit:prevent keydown.esc->bar#reset" } %>
<button class="search__reset btn btn--circle borderless" data-action="bar#clearInput">
<button hidden>Search</button>
<button class="search__reset btn btn--circle borderless" data-action="search-form#clearInput">
<%= icon_tag "close" %>
<span class="for-screen-reader">Close search</span>
</button>
+1 -1
View File
@@ -1,6 +1,6 @@
<section class="search">
<div class="search__results">
<% unless page.used? %>
<% if !page.used? && query %>
<div class="search__blank-slate blank-slate">
No matches
</div>
+3 -3
View File
@@ -1,4 +1,4 @@
<% @page_title = params.has_key?(:q) ? "Search results for \"#{params[:q]}\"" : "Search" %>
<% @page_title = @query ? "Search results for \"#{@query}\"" : "Search" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
@@ -9,12 +9,12 @@
<% end %>
<div class="search-perma margin-block-start">
<%= render "form", query_terms: params[:q] %>
<%= render "form", query_terms: @query %>
<%= turbo_frame_tag "bar_content" do %>
<% if @card %>
<%= auto_submit_form_with url: card_path(@card), method: :get, data: { turbo_action: "advance", turbo_frame: "_top", search_redirect: true } %>
<% else %>
<%= render "results", page: @page %>
<%= render "results", page: @page, query: @query %>
<% end %>
<% end %>
</div>
+1
View File
@@ -5,4 +5,5 @@ json.cache! user do
json.created_at user.created_at.utc
json.url user_url(user)
json.avatar_url user_avatar_url(user)
end
+2 -2
View File
@@ -6,7 +6,7 @@
</div>
<% end %>
<div class="panel panel--centered flex flex-column gap-half" style="--panel-size: 45ch;">
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-none">
<%= @page_title %>
</h1>
@@ -18,7 +18,7 @@
</label>
</div>
<p class="margin-none-block-start margin-block-end-half">Enter your new email address, then check your email to confirm the change.</p>
<p class="margin-none-block-start margin-block-end-half">Enter your new email address, then check your&nbsp;email to confirm the change.</p>
<button type="submit" class="btn btn--link center" data-form-target="submit">
<span>Continue</span>
+18
View File
@@ -2,13 +2,31 @@
PORT=3006
USE_TAILSCALE=0
USE_PUSH=0
for arg in "$@"; do
case $arg in
--tailscale) USE_TAILSCALE=1 ;;
--push) USE_PUSH=1 ;;
esac
done
if [ "$USE_PUSH" = "1" ]; then
if [ ! -f tmp/saas.txt ]; then
echo "Enabling SaaS mode for push notifications..."
./bin/rails saas:enable
fi
echo "Loading push credentials from 1Password..."
if ! eval "$(BUNDLE_GEMFILE=Gemfile.saas bundle exec push-dev)"; then
echo "Error: failed to load push credentials. Are you signed into 1Password?" >&2
exit 1
fi
if [ -z "$APNS_ENCRYPTION_KEY_B64" ] || [ -z "$APNS_KEY_ID" ]; then
echo "Error: Push credentials not set. Missing APNS_ENCRYPTION_KEY_B64 or APNS_KEY_ID." >&2
exit 1
fi
fi
if [ ! -f tmp/solid-queue.txt ]; then
export SOLID_QUEUE_IN_PUMA=false
fi
@@ -0,0 +1,3 @@
Rails.application.config.to_prepare do
Notification.register_push_target(:web)
end
+4
View File
@@ -245,6 +245,10 @@ Rails.application.routes.draw do
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
get "service-worker" => "pwa#service_worker"
# Mobile clients
get "client_configurations/(:platform)_v(:version)" => "client_configurations#show",
platform: /android|ios/, version: /\d+/
namespace :admin do
mount MissionControl::Jobs::Engine, at: "/jobs"
end

Some files were not shown because too many files have changed in this diff Show More