diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dbb544dc1..1efb99ec1 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ registries: type: git url: https://github.com username: x-access-token - password: ${{secrets.GH_TOKEN}} + password: ${{secrets.FIZZY_GH_TOKEN}} updates: - package-ecosystem: bundler diff --git a/.github/workflows/ci-saas.yml b/.github/workflows/ci-saas.yml index aa98a8e93..afb91c04d 100644 --- a/.github/workflows/ci-saas.yml +++ b/.github/workflows/ci-saas.yml @@ -18,4 +18,4 @@ jobs: with: saas: true secrets: - GH_TOKEN: ${{ secrets.GH_TOKEN }} + FIZZY_GH_TOKEN: ${{ secrets.FIZZY_GH_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index beee16f15..777471774 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ on: type: boolean required: true secrets: - GH_TOKEN: + FIZZY_GH_TOKEN: required: false permissions: @@ -50,7 +50,7 @@ jobs: MYSQL_USER: root FIZZY_DB_HOST: 127.0.0.1 FIZZY_DB_PORT: 3306 - BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.GH_TOKEN) || '' }} + BUNDLE_GITHUB__COM: ${{ inputs.saas && format('x-access-token:{0}', secrets.FIZZY_GH_TOKEN) || '' }} steps: - name: Install system packages diff --git a/.gitleaks.toml b/.gitleaks.toml index 4a1991496..ead929762 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -9,3 +9,11 @@ paths = [ '''docs/''', '''test/''', ] + +[[rules]] +id = "basecamp-integration-url" +description = "Basecamp Integration URL" +regex = '''https://[^\s]*?([0-9a-fA-F]{16,})''' +[rules.allowlist] +regexTarget = "match" +regexes = ['''github\.com'''] diff --git a/.rubocop.yml b/.rubocop.yml index 16773cd4c..c0d945b70 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,3 +11,5 @@ AllCops: Exclude: - 'db/migrate/**/*' - 'db/schema*.rb' + - 'saas/db/migrate/**/*' + - 'saas/db/saas_schema.rb' diff --git a/Gemfile.lock b/Gemfile.lock index 3a9430a61..dd1cbc16b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ GIT remote: https://github.com/basecamp/lexxy - revision: d292280b6d2c5d8a924327adf8bb9f0b25953539 + revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 specs: - lexxy (0.1.23.beta) + lexxy (0.1.24.beta) rails (>= 8.0.2) GIT @@ -122,8 +122,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1187.0) - aws-sdk-core (3.239.1) + aws-partitions (1.1197.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -134,7 +134,7 @@ GEM aws-sdk-kms (1.118.0) aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.205.0) + aws-sdk-s3 (1.208.0) aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -144,7 +144,7 @@ GEM bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) benchmark (0.5.0) - bigdecimal (3.3.1) + bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.19.0) msgpack (~> 1.2) @@ -190,6 +190,7 @@ GEM 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) @@ -267,7 +268,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - mittens (0.3.0) + mittens (0.3.1) mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -298,6 +299,8 @@ GEM racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) + nokogiri (1.18.10-x86_64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) @@ -422,6 +425,7 @@ GEM 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) sshkit (1.24.0) @@ -435,10 +439,11 @@ GEM railties (>= 6.0.0) stringio (3.1.9) thor (1.4.0) - thruster (0.1.16) - thruster (0.1.16-aarch64-linux) - thruster (0.1.16-arm64-darwin) - thruster (0.1.16-x86_64-linux) + 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) timeout (0.4.4) trilogy (2.9.0) tsort (0.2.0) @@ -481,6 +486,7 @@ PLATFORMS arm-linux-gnu arm-linux-musl arm64-darwin + x86_64-darwin-25 x86_64-linux x86_64-linux-gnu x86_64-linux-musl diff --git a/Gemfile.saas b/Gemfile.saas index 8681a31f6..05e6bbabd 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -3,10 +3,10 @@ eval_gemfile "Gemfile" git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" } -# SaaS-only functionality gem "activeresource", require: "active_resource" +gem "stripe", "~> 18.0" gem "queenbee", bc: "queenbee-plugin" -gem "fizzy-saas", bc: "fizzy-saas" +gem "fizzy-saas", path: "saas" gem "console1984", bc: "console1984" gem "audits1984", bc: "audits1984" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 0a40c4ecd..5ea235fb5 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -19,38 +19,16 @@ GIT rails (>= 7.0) rainbow -GIT - remote: https://github.com/basecamp/fizzy-saas - revision: 9c356dfeedbf5309f1a36ee3371f49604c9be188 - specs: - fizzy-saas (0.1.0) - audits1984 - console1984 - prometheus-client-mmap (~> 1.4.0) - queenbee - rails (>= 8.1.0.beta1) - rails_structured_logging - sentry-rails - sentry-ruby - yabeda - yabeda-actioncable - yabeda-activejob - yabeda-gc - yabeda-http_requests - yabeda-prometheus-mmap - yabeda-puma-plugin - yabeda-rails (>= 0.10) - GIT remote: https://github.com/basecamp/lexxy - revision: d292280b6d2c5d8a924327adf8bb9f0b25953539 + revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 specs: - lexxy (0.1.23.beta) + lexxy (0.1.24.beta) rails (>= 8.0.2) GIT remote: https://github.com/basecamp/queenbee-plugin - revision: 15faf03a876c5e66b67753d2e1ddb24f1eb5abb2 + revision: 14312a940471e20617b38cdec7c092a01567d18b specs: queenbee (3.2.0) activeresource @@ -181,6 +159,27 @@ GIT tsort (>= 0.2) zeitwerk (~> 2.6) +PATH + remote: saas + specs: + fizzy-saas (0.1.0) + audits1984 + console1984 + prometheus-client-mmap (~> 1.4.0) + queenbee + rails (>= 8.1.0.beta1) + rails_structured_logging + sentry-rails + sentry-ruby + yabeda + yabeda-actioncable + yabeda-activejob + yabeda-gc + yabeda-http_requests + yabeda-prometheus-mmap + yabeda-puma-plugin + yabeda-rails (>= 0.10) + GEM remote: https://rubygems.org/ specs: @@ -201,8 +200,8 @@ GEM ast (2.4.3) autotuner (1.1.0) aws-eventstream (1.4.0) - aws-partitions (1.1187.0) - aws-sdk-core (3.239.1) + aws-partitions (1.1197.0) + aws-sdk-core (3.240.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -213,7 +212,7 @@ GEM aws-sdk-kms (1.118.0) aws-sdk-core (~> 3, >= 3.239.1) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.205.0) + aws-sdk-s3 (1.208.0) aws-sdk-core (~> 3, >= 3.234.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -223,7 +222,7 @@ GEM bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) benchmark (0.5.0) - bigdecimal (3.3.1) + bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.19.0) msgpack (~> 1.2) @@ -347,7 +346,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - mittens (0.3.0) + mittens (0.3.1) mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -558,11 +557,12 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.9) + stripe (18.0.1) thor (1.4.0) - thruster (0.1.16) - thruster (0.1.16-aarch64-linux) - thruster (0.1.16-arm64-darwin) - thruster (0.1.16-x86_64-linux) + thruster (0.1.17) + thruster (0.1.17-aarch64-linux) + thruster (0.1.17-arm64-darwin) + thruster (0.1.17-x86_64-linux) timeout (0.4.4) trilogy (2.9.0) tsort (0.2.0) @@ -684,6 +684,7 @@ DEPENDENCIES solid_queue (~> 1.2) sqlite3 (>= 2.0) stimulus-rails + stripe (~> 18.0) thruster trilogy (~> 2.9) turbo-rails diff --git a/app/assets/images/history.svg b/app/assets/images/history.svg new file mode 100644 index 000000000..20c490c97 --- /dev/null +++ b/app/assets/images/history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/images/install.svg b/app/assets/images/install.svg deleted file mode 100644 index 82608c765..000000000 --- a/app/assets/images/install.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 213d921e4..d475d0628 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -1,4 +1,4 @@ -@layer reset, base, components, modules, utilities; +@layer reset, base, components, modules, utilities, native, platform; :root { /* Spacing */ diff --git a/app/assets/stylesheets/android.css b/app/assets/stylesheets/android.css new file mode 100644 index 000000000..f30158e8e --- /dev/null +++ b/app/assets/stylesheets/android.css @@ -0,0 +1,7 @@ +@layer platform { + [data-platform~=android] { + .hide-on-android { + display: none; + } + } +} diff --git a/app/assets/stylesheets/animation.css b/app/assets/stylesheets/animation.css index d6f05efa9..b38c4ce82 100644 --- a/app/assets/stylesheets/animation.css +++ b/app/assets/stylesheets/animation.css @@ -71,6 +71,15 @@ 33% { background-color: var(--color-border-darker); scale: 1; } } + @keyframes wiggle { + 0% { transform: rotate(0deg); } + 20% { transform: rotate(3deg); } + 40% { transform: rotate(-3deg); } + 60% { transform: rotate(3deg); } + 80% { transform: rotate(-3deg); } + 100% { transform: rotate(0deg); } + } + @keyframes wobble { 0% { transform: rotate(calc(var(--bubble-rotate) + 30deg)); } 15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; } diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index e08b5748d..38f567444 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -481,7 +481,23 @@ font-size: clamp(0.6rem, 0.85cqi, 100px); } + .card__comments { + --column-gap: 0.8ch; + + display: flex; + margin-block-end: calc(var(--block-space) * -1.7); + margin-inline-end: calc(var(--card-padding-inline) * -0.5); + + .icon { + --icon-size: 1.6em; + + color: var(--card-color); + } + } + .card__steps { + --column-gap: 0.8ch; + display: flex; } diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 3132d886f..59a80a32a 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -277,7 +277,7 @@ border-radius: 99rem; } - .btn:not(.popup__btn, .btn--plain, .btn--reversed) { + .btn:not(.popup__btn, .btn--plain, .btn--reversed, .settings-subscription__button) { --btn-background: var(--card-color); --btn-color: var(--color-ink-inverted); } @@ -299,6 +299,19 @@ } } + .card-perma__notch-new-card-buttons { + display: flex; + gap: var(--inline-space-half); + + @media (max-width: 479px) { + flex-direction: column; + + .btn { + inline-size: 100%; + } + } + } + .card-perma__closure-message { color: var(--card-color); } diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 80968184e..c3dbb7d74 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -23,6 +23,7 @@ } .comment { + display: flex; margin-inline: auto; max-inline-size: var(--comment-max); position: relative; @@ -34,6 +35,56 @@ .house-md-content { padding: var(--comment-padding-block) 0 0; } + + .comment--system & { + --comment-padding-block: var(--block-space-half); + + text-align: center; + + &::before { + /* Make up space for lack of avatar */ + content: ""; + display: flex; + inline-size: calc(var(--comment-padding-inline) * 0.9); + } + + .comment__avatar { + display: none; + } + + .comment__author { + a { margin: 0 auto; } + h3 { margin-inline: auto; } + strong { display: none; } + } + + .comment__body { + padding: 0; + text-align: center; + } + + .comment__content { + --stripe-color: var(--color-ink-lightest); + + background-image: repeating-linear-gradient( + 45deg in srgb, + var(--color-canvas) 0 1px, + var(--stripe-color) 1px 10px); + padding-inline: var(--comment-padding-inline); + + .comments--system-expanded .comment--system & { + --stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); + } + + .comment__history { + background-color: var(--stripe-color); + } + } + + .reactions { + display: none !important; + } + } } .comment__author { @@ -48,7 +99,6 @@ } .comment__body { - padding-inline-end: var(--reaction-size); text-align: start; .action-text-content { @@ -60,6 +110,10 @@ margin-block-end: 0; } } + + &:not:has(lexxy-editor) { + padding-inline-end: var(--reaction-size); + } } .comment__content { @@ -70,6 +124,7 @@ background-color: var(--comment-bg-color); border-radius: 0.2em; + max-inline-size: calc(100% - calc(var(--comment-padding-inline) * 0.75)); padding: var(--comment-padding-block) calc(var(--comment-padding-inline) / 2) @@ -82,53 +137,36 @@ background-color: var(--color-ink-lightest); } + .comment__history { + background-color: var(--color-ink-lightest); + display: none; + inset: calc(var(--comment-padding-inline) / 2.5) calc(var(--comment-padding-inline) / 1.5) auto auto; margin-inline-end: calc(var(--comment-padding-inline) / -2.5); + position: absolute; + } + .comment--system { - --comment-padding-block: var(--block-space-half); + display: none; + transition: var(--dialog-duration) allow-discrete; + transition-property: display; - max-inline-size: var(--comment-max); - text-align: center; - - &::before { - /* Make up space for lack of avatar */ - content: ""; - display: flex; - inline-size: calc(var(--comment-padding-inline) * 0.75); + .comments--system-expanded & { + display: contents; } + } - .comment__avatar { + /* Show the last system comment */ + :nth-last-child(1 of .comment--system) { + display: contents; + + .comment__history { + display: grid; + } + } + + /* Hide the "Show history" button if there's only one system comment */ + :nth-child(1 of .comment--system) { + .comment__history { display: none; } - - .comment__author { - a { - margin: 0 auto; - } - - h3 { - margin-inline: auto; - } - - strong { - display: none; - } - } - - .comment__body { - padding: 0; - text-align: center; - } - - .comment__content { - background-image: repeating-linear-gradient( - 45deg in srgb, - var(--color-canvas) 0 1px, - var(--color-ink-lightest) 1px 10px - ); - padding-inline: var(--comment-padding-inline); - } - - .reactions { - display: none !important; - } } } diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 2e269b9d0..42a3a21ec 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -59,9 +59,8 @@ .icon--fizzy { --svg: url("fizzy.svg"); } .icon--globe { --svg: url("globe.svg "); } .icon--golden-ticket { --svg: url("golden-ticket.svg "); } + .icon--history { --svg: url("history.svg "); } .icon--home { --svg: url("home.svg "); } - .icon--install { --svg: url("install.svg "); } - .icon--install { --svg: url("install.svg "); } .icon--install-edge { --svg: url("install-edge.svg "); } .icon--lifebuoy { --svg: url("lifebuoy.svg "); } .icon--lock { --svg: url("lock.svg "); } diff --git a/app/assets/stylesheets/ios.css b/app/assets/stylesheets/ios.css new file mode 100644 index 000000000..3a05e5956 --- /dev/null +++ b/app/assets/stylesheets/ios.css @@ -0,0 +1,7 @@ +@layer platform { + [data-platform~=ios] { + .hide-on-ios { + display: none; + } + } +} diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index c90d5f6b9..21ee18301 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -38,7 +38,7 @@ } .lexxy-editor__content { - margin: var(--block-space-half) 0; + margin-block-start: var(--block-space-half); min-block-size: calc(7lh + var(--block-space)); outline: 0; @@ -46,6 +46,10 @@ ::selection { background: oklch(var(--lch-blue-light) / 0.5); } + + > :last-child { + margin-block-end: 0; + } } .lexxy-editor--drag-over { @@ -227,12 +231,15 @@ [data-button-group] { display: flex; flex-direction: row; - flex-wrap: wrap; gap: var(--gap); + & { margin-block-start: var(--gap); } + + @media (max-width: 639px) { + flex-wrap: wrap; + } } } @@ -297,17 +304,12 @@ font-size: var(--text-small); list-style: none; margin: 0; + max-height: 200px; min-inline-size: var(--lexxy-prompt-min-width); + overflow: auto; padding: var(--lexxy-prompt-padding); visibility: hidden; z-index: var(--z-popup); - - max-height: 200px; - overflow: scroll; - - &:is(.lexxy-prompt-menu--visible) { - padding: var(--lexxy-prompt-padding); - } } .lexxy-prompt-menu--visible { diff --git a/app/assets/stylesheets/lightbox.css b/app/assets/stylesheets/lightbox.css index 4a9cb3d8b..65144a22b 100644 --- a/app/assets/stylesheets/lightbox.css +++ b/app/assets/stylesheets/lightbox.css @@ -67,7 +67,7 @@ } .lightbox__caption { - color: var(--color-ink-inverted); + color: var(--color-white); &:empty { display: none; diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css new file mode 100644 index 000000000..f7b059c0c --- /dev/null +++ b/app/assets/stylesheets/native.css @@ -0,0 +1,13 @@ +@layer native { + [data-platform~=native] { + /* The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */ + --custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px)); + --custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px)); + --custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px)); + --custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px)); + + .hide-on-native { + display: none; + } + } +} diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index 0213d04cb..189cfef5b 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -36,6 +36,15 @@ } } + .popup__footer { + border-block-start: 1px solid var(--color-ink-lightest); + color: var(--card-color); + margin-block-start: var(--popup-item-padding-inline); + padding: var(--popup-item-padding-inline) var(--popup-item-padding-inline) 0; + text-align: center; + text-transform: initial; + } + .popup__title { font-weight: 800; white-space: nowrap; diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index 797e9a7cb..5dea448cc 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -24,6 +24,10 @@ margin: 0; position: absolute; + @media (max-width: 640px) { + inset-inline-end: calc(var(--comment-padding-inline) / 3); + } + .reactions__list { display: none; } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 70f095bca..88e4d2ae2 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -1,8 +1,8 @@ @layer components { - .rich-text-content { + :where(.rich-text-content) { --block-margin: 0.5lh; - :where(h1, h2, h3, h4, h5, h6) { + h1, h2, h3, h4, h5, h6 { display: block; font-weight: 800; letter-spacing: -0.02ch; @@ -12,14 +12,14 @@ text-wrap: balance; } - :where(h1) { font-size: 2em; } - :where(h2) { font-size: 1.5em; } - :where(h3) { font-size: 1.17em; } - :where(h4) { font-size: 1em; } - :where(h5) { font-size: 0.83em; } - :where(h6) { font-size: 0.67em; } + h1 { font-size: 2em; } + h2 { font-size: 1.5em; } + h3 { font-size: 1.17em; } + h4 { font-size: 1em; } + h5 { font-size: 0.83em; } + h6 { font-size: 0.67em; } - :where(p, ul, ol, dl, blockquote, figure, .attachment) { + p, ul, ol, dl, blockquote, figure, .attachment { margin-block: 0 var(--block-margin); &:not(lexxy-editor &) { @@ -28,49 +28,65 @@ } } - :where(ol, ul) { - list-style: disc; + p:has(+ p) { + margin: 0; + } + + ol, ul { padding-inline-start: 3ch; } - :where(li:has(li)) { + ol { + list-style: decimal; + } + + ul { + list-style: disc; + } + + li:has(li) { list-style: none; - :where(ol, ul) { + ol, ul { margin: 0; padding-inline-start: 2ch; } } - :where(b, strong, .lexxy-content__bold) { + b, strong, .lexxy-content__bold { font-weight: 700; } - :where(i, em, .lexxy-content__italic) { + i, em, .lexxy-content__italic { font-style: italic; } - :where(s, .lexxy-content__strikethrough) { + s, .lexxy-content__strikethrough { text-decoration: line-through; } - :where(mark, .lexxy-content__highlight) { + mark, .lexxy-content__highlight { background-color: transparent; color: inherit; } - :where(p, blockquote) { + p, blockquote { letter-spacing: -0.005ch; } - :where(blockquote) { + /* Avoid extra space due to empty paragraphs */ + p:empty { + display: none; + } + + blockquote { border-inline-start: 0.25em solid var(--color-ink-lighter); font-style: italic; margin: var(--block-margin) 0; padding-inline-start: 2ch; } - :where(img, video, embed, object) { + img, video, embed, object { inline-size: auto; margin-inline: auto; max-block-size: 32rem; @@ -81,14 +97,10 @@ a:has(img), a:has(video) { inline-size: fit-content; + margin-inline: auto; } - /* Avoid extra space due to empty paragraphs */ - p:empty { - display: none; - } - - :where(hr) { + hr { color: currentColor; block-size: 0; border: none; @@ -103,7 +115,7 @@ } /* Code */ - :where(code, pre) { + code, pre { background-color: var(--color-canvas); border: 1px solid var(--color-ink-lighter); border-radius: 0.3em; @@ -209,7 +221,7 @@ position: relative; max-inline-size: 100%; - :where(progress) { + progress { inline-size: 100%; margin: auto; } diff --git a/app/assets/stylesheets/steps.css b/app/assets/stylesheets/steps.css index f106859ac..b9b40c8fa 100644 --- a/app/assets/stylesheets/steps.css +++ b/app/assets/stylesheets/steps.css @@ -93,17 +93,14 @@ } .steps__icon { + --icon-size: 0.875em; + background-color: var(--card-color); block-size: 1.3em; border-radius: 50%; + color: var(--color-ink-inverted); display: grid; inline-size: 1.3em; place-content: center; - - .icon { - background-color: var(--color-ink-inverted); - block-size: 0.8em; - inline-size: 0.8em; - } } -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 3cefc1ecc..7cc90f92d 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -424,6 +424,7 @@ .card__meta-text:not(.card__meta-text--updated), .card__stages, .card__steps, + .card__comments, .card__closed { display: none; } diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css index 80cbfcdca..0d00d9977 100644 --- a/app/assets/stylesheets/utilities.css +++ b/app/assets/stylesheets/utilities.css @@ -178,6 +178,23 @@ .fill-highlight { background-color: var(--color-highlight); } .fill-transparent { background-color: transparent; } + .fill-highlighter { + display: inline-block; + position: relative; + z-index: 1; + + &::before { + background-color: var(--color-highlight); + border-radius: 0.2em; + content: ""; + inset-block: 0; + inset-inline: -0.1em; + position: absolute; + transform: skewX(-10deg) rotate(1deg); + z-index: -1; + } + } + .translucent { opacity: var(--opacity, 0.66); } /* Borders */ @@ -246,6 +263,12 @@ } } + .show-on-native { + body:not([data-platform~=native]) & { + display: none; + } + } + .hide-scrollbar { -ms-overflow-style: none; /* Edge */ scrollbar-width: none; /* FF */ diff --git a/app/controllers/boards/columns_controller.rb b/app/controllers/boards/columns_controller.rb index 9e14eb937..4b71e7f3e 100644 --- a/app/controllers/boards/columns_controller.rb +++ b/app/controllers/boards/columns_controller.rb @@ -35,7 +35,7 @@ class Boards::ColumnsController < ApplicationController @column.destroy respond_to do |format| - format.turbo_stream + format.html { redirect_back_or_to @board } format.json { head :no_content } end end diff --git a/app/controllers/boards_controller.rb b/app/controllers/boards_controller.rb index 6c8a17575..721e69b0d 100644 --- a/app/controllers/boards_controller.rb +++ b/app/controllers/boards_controller.rb @@ -83,7 +83,7 @@ class BoardsController < ApplicationController def show_columns cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded set_page_and_extract_portion_from cards - fresh_when etag: [ @board, @page.records, @user_filtering ] + fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ] end def board_params diff --git a/app/controllers/cards/assignments_controller.rb b/app/controllers/cards/assignments_controller.rb index 396fd3a5f..3e9fe1d48 100644 --- a/app/controllers/cards/assignments_controller.rb +++ b/app/controllers/cards/assignments_controller.rb @@ -8,11 +8,16 @@ class Cards::AssignmentsController < ApplicationController end def create - @card.toggle_assignment @board.users.active.find(params[:assignee_id]) - - respond_to do |format| - format.turbo_stream - format.json { head :no_content } + if @card.toggle_assignment @board.users.active.find(params[:assignee_id]) + respond_to do |format| + format.turbo_stream + format.json { head :no_content } + end + else + respond_to do |format| + format.turbo_stream + format.json { head :unprocessable_entity } + end end end end diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb index 83706ddf4..bfdc1ee2e 100644 --- a/app/controllers/cards_controller.rb +++ b/app/controllers/cards_controller.rb @@ -61,6 +61,6 @@ class CardsController < ApplicationController end def card_params - params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ]) + params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at ]) end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 08129d9c7..05efbf4fc 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -4,13 +4,12 @@ module Authentication included do before_action :require_account # Checking and setting account must happen first before_action :require_authentication - after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? helper_method :email_address_pending_authentication etag { Current.identity.id if authenticated? } - include LoginHelper + include Authentication::ViaMagicLink, LoginHelper end class_methods do @@ -38,7 +37,7 @@ module Authentication def require_account unless Current.account.present? - redirect_to main_app.session_menu_url(script_name: nil) + redirect_to main_app.session_menu_path(script_name: nil) end end @@ -102,35 +101,7 @@ module Authentication cookies.delete(:session_token) end - def ensure_development_magic_link_not_leaked - unless Rails.env.development? - raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? - end - end - - def email_address_pending_authentication_matches?(email_address) - if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "") - session.delete(:email_address_pending_authentication) - true - else - false - end - end - - def email_address_pending_authentication - session[:email_address_pending_authentication] - end - - def redirect_to_session_magic_link(magic_link, return_to: nil) - serve_development_magic_link(magic_link) - session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link - session[:return_to_after_authenticating] = return_to if return_to - redirect_to main_app.session_magic_link_url(script_name: nil) - end - - def serve_development_magic_link(magic_link) - if Rails.env.development? - flash[:magic_link_code] = magic_link&.code - end + def session_token + cookies[:session_token] end end diff --git a/app/controllers/concerns/authentication/via_magic_link.rb b/app/controllers/concerns/authentication/via_magic_link.rb new file mode 100644 index 000000000..e9c0dc415 --- /dev/null +++ b/app/controllers/concerns/authentication/via_magic_link.rb @@ -0,0 +1,67 @@ +module Authentication::ViaMagicLink + extend ActiveSupport::Concern + + included do + after_action :ensure_development_magic_link_not_leaked + end + + private + def ensure_development_magic_link_not_leaked + unless Rails.env.development? + raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? + end + end + + def redirect_to_fake_session_magic_link(email_address, **options) + fake_magic_link = MagicLink.new( + identity: Identity.new(email_address: email_address), + code: SecureRandom.base32(6), + expires_at: MagicLink::EXPIRATION_TIME.from_now + ) + + redirect_to_session_magic_link fake_magic_link, **options + end + + def redirect_to_session_magic_link(magic_link, return_to: nil) + serve_development_magic_link(magic_link) + set_pending_authentication_token(magic_link) + session[:return_to_after_authenticating] = return_to if return_to + + respond_to do |format| + format.html { redirect_to main_app.session_magic_link_url(script_name: nil) } + format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created } + end + end + + def serve_development_magic_link(magic_link) + if Rails.env.development? && magic_link.present? + flash[:magic_link_code] = magic_link.code + response.set_header("X-Magic-Link-Code", magic_link.code) + end + end + + def set_pending_authentication_token(magic_link) + cookies[:pending_authentication_token] = { + value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at), + httponly: true, + same_site: :lax, + expires: magic_link.expires_at + } + end + + def email_address_pending_authentication + pending_authentication_token_verifier.verified(pending_authentication_token) + end + + def pending_authentication_token_verifier + Rails.application.message_verifier(:pending_authentication) + end + + def pending_authentication_token + cookies[:pending_authentication_token] + end + + def clear_pending_authentication_token + cookies.delete(:pending_authentication_token) + end +end diff --git a/app/controllers/concerns/authorization.rb b/app/controllers/concerns/authorization.rb index 81bd0c781..1afb878ef 100644 --- a/app/controllers/concerns/authorization.rb +++ b/app/controllers/concerns/authorization.rb @@ -3,7 +3,6 @@ module Authorization included do before_action :ensure_can_access_account, if: -> { Current.account.present? && authenticated? } - before_action :ensure_only_staff_can_access_non_production_remote_environments, if: :authenticated? end class_methods do @@ -27,11 +26,7 @@ module Authorization end def ensure_can_access_account - redirect_to session_menu_url(script_name: nil) if Current.user.blank? || !Current.user.active? - end - - def ensure_only_staff_can_access_non_production_remote_environments - head :forbidden unless Rails.env.local? || Rails.env.production? || Current.identity.staff? + redirect_to session_menu_path(script_name: nil) if Current.user.blank? || !Current.user.active? end def redirect_existing_user diff --git a/app/controllers/concerns/filter_scoped.rb b/app/controllers/concerns/filter_scoped.rb index a9341f604..5507c9b8d 100644 --- a/app/controllers/concerns/filter_scoped.rb +++ b/app/controllers/concerns/filter_scoped.rb @@ -16,7 +16,7 @@ module FilterScoped end def filter_params - params.reverse_merge(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS) + params.with_defaults(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS) end def set_user_filtering diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index 8bd67c9dc..f2ec2588c 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -1,5 +1,6 @@ class JoinCodesController < ApplicationController allow_unauthenticated_access + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { head :too_many_requests } before_action :set_join_code before_action :ensure_join_code_is_valid diff --git a/app/controllers/my/menus_controller.rb b/app/controllers/my/menus_controller.rb index 7a5754593..32b3da004 100644 --- a/app/controllers/my/menus_controller.rb +++ b/app/controllers/my/menus_controller.rb @@ -4,7 +4,8 @@ class My::MenusController < ApplicationController @boards = Current.user.boards.ordered_by_recently_accessed @tags = Current.account.tags.all.alphabetically @users = Current.account.users.active.alphabetically + @accounts = Current.identity.accounts - fresh_when etag: [ @filters, @boards, @tags, @users ] + fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ] end end diff --git a/app/controllers/public/base_controller.rb b/app/controllers/public/base_controller.rb index b4c33e95b..018b1ab36 100644 --- a/app/controllers/public/base_controller.rb +++ b/app/controllers/public/base_controller.rb @@ -11,7 +11,7 @@ class Public::BaseController < ApplicationController end def set_card - @card = @board.cards.find_by!(number: params[:id]) if params[:board_id] && params[:id] + @card = @board.cards.published.find_by!(number: params[:id]) if params[:board_id] && params[:id] end def set_public_cache_expiration diff --git a/app/controllers/sessions/magic_links_controller.rb b/app/controllers/sessions/magic_links_controller.rb index c0632407a..32257d941 100644 --- a/app/controllers/sessions/magic_links_controller.rb +++ b/app/controllers/sessions/magic_links_controller.rb @@ -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: "Wait 15 minutes, then try again" } + rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded before_action :ensure_that_email_address_pending_authentication_exists layout "public" @@ -11,25 +11,20 @@ class Sessions::MagicLinksController < ApplicationController def create if magic_link = MagicLink.consume(code) - authenticate_with magic_link + authenticate magic_link else - redirect_to session_magic_link_path, flash: { shake: true } + invalid_code end end private def ensure_that_email_address_pending_authentication_exists unless email_address_pending_authentication.present? - redirect_to new_session_path, alert: "Enter your email address to sign in." - end - end - - def authenticate_with(magic_link) - if email_address_pending_authentication_matches?(magic_link.identity.email_address) - start_new_session_for magic_link.identity - redirect_to after_sign_in_url(magic_link) - else - redirect_to new_session_path, alert: "Authentication failed. Please try again." + alert_message = "Enter your email address to sign in." + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end end end @@ -37,6 +32,41 @@ class Sessions::MagicLinksController < ApplicationController params.expect(:code) end + def authenticate(magic_link) + if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address) + sign_in magic_link + else + email_address_mismatch + end + end + + def sign_in(magic_link) + clear_pending_authentication_token + start_new_session_for magic_link.identity + + respond_to do |format| + format.html { redirect_to after_sign_in_url(magic_link) } + format.json { render json: { session_token: session_token } } + end + end + + def email_address_mismatch + clear_pending_authentication_token + alert_message = "Something went wrong. Please try again." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: alert_message } + format.json { render json: { message: alert_message }, status: :unauthorized } + end + end + + def invalid_code + respond_to do |format| + format.html { redirect_to session_magic_link_path, flash: { shake: true } } + format.json { render json: { message: "Try another code." }, status: :unauthorized } + end + end + def after_sign_in_url(magic_link) if magic_link.for_sign_up? new_signup_completion_path @@ -44,4 +74,12 @@ class Sessions::MagicLinksController < ApplicationController after_authentication_url end end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again in 15 minutes." + respond_to do |format| + format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f5044bd2a..d0de9e84c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,7 +1,7 @@ class SessionsController < ApplicationController disallow_account_scope require_unauthenticated_access except: :destroy - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." } + rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded layout "public" @@ -9,16 +9,12 @@ class SessionsController < ApplicationController end def create - if identity = Identity.find_by_email_address(email_address) - redirect_to_session_magic_link identity.send_magic_link + if identity = Identity.find_by(email_address: email_address) + sign_in identity + elsif Account.accepting_signups? + sign_up else - signup = Signup.new(email_address: email_address) - if signup.valid?(:identity_creation) - magic_link = signup.create_identity if Account.accepting_signups? - redirect_to_session_magic_link magic_link - else - head :unprocessable_entity - end + redirect_to_fake_session_magic_link email_address end end @@ -28,7 +24,43 @@ class SessionsController < ApplicationController end private + def magic_link_from_sign_in_or_sign_up + if identity = Identity.find_by_email_address(email_address) + identity.send_magic_link + else + signup = Signup.new(email_address: email_address) + signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups? + end + end + def email_address params.expect(:email_address) end + + def rate_limit_exceeded + rate_limit_exceeded_message = "Try again later." + + respond_to do |format| + format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message } + format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests } + end + end + + def sign_in(identity) + redirect_to_session_magic_link identity.send_magic_link + end + + def sign_up + signup = Signup.new(email_address: email_address) + + if signup.valid?(:identity_creation) + magic_link = signup.create_identity + redirect_to_session_magic_link magic_link + else + respond_to do |format| + format.html { redirect_to new_session_path, alert: "Something went wrong" } + format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity } + end + end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b3657c76e..946a0900e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -10,12 +10,6 @@ module ApplicationHelper tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options end - def inline_svg(name) - file_path = "#{Rails.root}/app/assets/images/#{name}.svg" - return File.read(file_path).html_safe if File.exist?(file_path) - "(not found)" - end - def back_link_to(label, url, action, **options) link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index 222d41357..f48b5d557 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -43,7 +43,7 @@ module ColumnsHelper end def column_frame_tag(id, src: nil, data: {}, **options, &block) - data = data.reverse_merge \ + data = data.with_defaults \ drag_and_drop_refresh: true, controller: "frame", action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload" diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index f0e774882..6eb70f2cc 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -3,6 +3,8 @@ module MessagesHelper turbo_frame_tag dom_id(card, :messages), class: "comments gap center", style: "--card-color: #{card.color}", - role: "group", aria: { label: "Messages" }, & + role: "group", + aria: { label: "Messages" }, + data: { controller: "toggle-class", toggle_class_toggle_class: "comments--system-expanded" }, & end end diff --git a/app/javascript/controllers/assignment_limit_controller.js b/app/javascript/controllers/assignment_limit_controller.js new file mode 100644 index 000000000..06f630c59 --- /dev/null +++ b/app/javascript/controllers/assignment_limit_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { limit: Number, count: Number } + static targets = ["unassigned", "limitMessage"] + + connect() { + this.updateState() + } + + countValueChanged() { + this.updateState() + } + + updateState() { + const atLimit = this.countValue >= this.limitValue + + this.unassignedTargets.forEach(el => { + el.hidden = atLimit + }) + + if (this.hasLimitMessageTarget) { + this.limitMessageTarget.hidden = !atLimit + } + } +} diff --git a/app/javascript/controllers/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js index e8bc48f03..fc86b18f7 100644 --- a/app/javascript/controllers/lightbox_controller.js +++ b/app/javascript/controllers/lightbox_controller.js @@ -3,9 +3,22 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "caption", "image", "dialog", "zoomedImage" ] - open(event) { + imageTargetConnected(element) { + element.addEventListener("click", this.#handleImageClick) + } + + imageTargetDisconnected(element) { + element.removeEventListener("click", this.#handleImageClick) + } + + #handleImageClick = (event) => { + event.preventDefault() + this.#open(event.currentTarget) + } + + #open(link) { this.dialogTarget.showModal() - this.#set(event.target.closest("a")) + this.#set(link) } // Wait for the transition to finish before resetting the image diff --git a/app/javascript/helpers/http_helpers.js b/app/javascript/helpers/http_helpers.js deleted file mode 100644 index 1fb986f70..000000000 --- a/app/javascript/helpers/http_helpers.js +++ /dev/null @@ -1,4 +0,0 @@ -export const HttpStatus = { - CONFLICT: 409, - UNPROCESSABLE: 422 -} diff --git a/app/models/account.rb b/app/models/account.rb index df0b845b0..f9b2589c0 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -19,7 +19,7 @@ class Account < ApplicationRecord def create_with_owner(account:, owner:) create!(**account).tap do |account| account.users.create!(role: :system, name: "System") - account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current)) + account.users.create!(**owner.with_defaults(role: :owner, verified_at: Time.current)) end end end diff --git a/app/models/account/seeder.rb b/app/models/account/seeder.rb index 4a349b939..ff97538be 100644 --- a/app/models/account/seeder.rb +++ b/app/models/account/seeder.rb @@ -69,6 +69,7 @@ class Account::Seeder
  • Make another called "Working on"
  • Go back to the Board view, click the little “+” to the right of the DONE column, name the column, pick a color, then do it again.

    +


    After that, drag this card to “DONE” or select “DONE” in the sidebar.

    HTML diff --git a/app/models/admin.rb b/app/models/admin.rb new file mode 100644 index 000000000..53608e3eb --- /dev/null +++ b/app/models/admin.rb @@ -0,0 +1,2 @@ +module Admin +end diff --git a/app/models/application_platform.rb b/app/models/application_platform.rb index 500f4adc6..9b2695cd5 100644 --- a/app/models/application_platform.rb +++ b/app/models/application_platform.rb @@ -35,10 +35,26 @@ class ApplicationPlatform < PlatformAgent !mobile? end + def native? + match? /Hotwire Native/ + end + def windows? operating_system == "Windows" end + def type + if native? && android? + "native android" + elsif native? && ios? + "native ios" + elsif mobile? + "mobile web" + else + "desktop web" + end + end + def operating_system case user_agent.platform when /Android/ then "Android" diff --git a/app/models/assignment.rb b/app/models/assignment.rb index f60eb974d..75ffde959 100644 --- a/app/models/assignment.rb +++ b/app/models/assignment.rb @@ -1,7 +1,18 @@ class Assignment < ApplicationRecord + LIMIT = 100 + belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :assignee, class_name: "User" belongs_to :assigner, class_name: "User" + + validate :within_limit, on: :create + + private + def within_limit + if card.assignments.count >= LIMIT + errors.add(:base, "Card already has the maximum of #{LIMIT} assignees") + end + end end diff --git a/app/models/card/assignable.rb b/app/models/card/assignable.rb index be0dd257e..6320faeb3 100644 --- a/app/models/card/assignable.rb +++ b/app/models/card/assignable.rb @@ -24,10 +24,12 @@ module Card::Assignable private def assign(user) - assignments.create! assignee: user, assigner: Current.user - watch_by user + assignment = assignments.create assignee: user, assigner: Current.user - track_event :assigned, assignee_ids: [ user.id ] + if assignment.persisted? + watch_by user + track_event :assigned, assignee_ids: [ user.id ] + end rescue ActiveRecord::RecordNotUnique # Already assigned end diff --git a/app/models/card/closeable.rb b/app/models/card/closeable.rb index 51852c9a5..e4cea509f 100644 --- a/app/models/card/closeable.rb +++ b/app/models/card/closeable.rb @@ -7,9 +7,9 @@ module Card::Closeable scope :closed, -> { joins(:closure) } scope :open, -> { where.missing(:closure) } - scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) } - scope :closed_at_window, ->(window) { closed.where("closures.created_at": window) } - scope :closed_by, ->(users) { closed.where("closures.user_id": Array(users)) } + scope :recently_closed_first, -> { closed.order(closures: { created_at: :desc }) } + scope :closed_at_window, ->(window) { closed.where(closures: { created_at: window }) } + scope :closed_by, ->(users) { closed.where(closures: { user_id: Array(users) }) } end def closed? diff --git a/app/models/card/stallable.rb b/app/models/card/stallable.rb index 545cc87ee..03348dfde 100644 --- a/app/models/card/stallable.rb +++ b/app/models/card/stallable.rb @@ -7,7 +7,7 @@ module Card::Stallable has_one :activity_spike, class_name: "Card::ActivitySpike", dependent: :destroy scope :with_activity_spikes, -> { joins(:activity_spike) } - scope :stalled, -> { open.active.with_activity_spikes.where("card_activity_spikes.updated_at": ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) } + scope :stalled, -> { open.active.with_activity_spikes.where(card_activity_spikes: { updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago }, updated_at: ..STALLED_AFTER_LAST_SPIKE_PERIOD.ago) } before_update :remember_to_detect_activity_spikes after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes? diff --git a/app/models/card/statuses.rb b/app/models/card/statuses.rb index 5311c211e..2dc235c1e 100644 --- a/app/models/card/statuses.rb +++ b/app/models/card/statuses.rb @@ -6,8 +6,6 @@ module Card::Statuses before_save :mark_if_just_published after_create -> { track_event :published }, if: :published? - - scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) } end attr_accessor :was_just_published diff --git a/app/models/column/positioned.rb b/app/models/column/positioned.rb index 8518172de..c6ef105da 100644 --- a/app/models/column/positioned.rb +++ b/app/models/column/positioned.rb @@ -31,6 +31,10 @@ module Column::Positioned right_column.nil? end + def adjacent_columns + board.columns.where(id: [ left_column&.id, right_column&.id ].compact) + end + private def set_position max_position = board.columns.maximum(:position) || 0 diff --git a/app/models/comment.rb b/app/models/comment.rb index a396a904f..d0315eea8 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -10,8 +10,8 @@ class Comment < ApplicationRecord scope :chronologically, -> { order created_at: :asc, id: :desc } scope :preloaded, -> { with_rich_text_body.includes(reactions: :reacter) } - scope :by_system, -> { joins(:creator).where(creator: { role: "system" }) } - scope :by_user, -> { joins(:creator).where.not(creator: { role: "system" }) } + scope :by_system, -> { joins(:creator).where(creator: { role: :system }) } + scope :by_user, -> { joins(:creator).where.not(creator: { role: :system }) } after_create_commit :watch_card_by_creator diff --git a/app/models/filter.rb b/app/models/filter.rb index 9dac977bd..9bfefe176 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -29,7 +29,7 @@ class Filter < ApplicationRecord result = result.where(creator_id: creators.ids) if creators.present? result = result.where(board: boards.ids) if boards.present? result = result.tagged_with(tags.ids) if tags.present? - result = result.where("cards.created_at": creation_window) if creation_window + result = result.where(cards: { created_at: creation_window }) if creation_window result = result.closed_at_window(closure_window) if closure_window result = result.closed_by(closers) if closers.present? result = terms.reduce(result) do |result, term| diff --git a/app/models/signup.rb b/app/models/signup.rb index 110c253ef..041f461ac 100644 --- a/app/models/signup.rb +++ b/app/models/signup.rb @@ -8,6 +8,7 @@ class Signup validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation validates :full_name, :identity, presence: true, on: :completion + validates :full_name, length: { maximum: 240 } def initialize(...) super diff --git a/app/models/time_window_parser.rb b/app/models/time_window_parser.rb index a8da2a580..cdfc13c5f 100644 --- a/app/models/time_window_parser.rb +++ b/app/models/time_window_parser.rb @@ -31,21 +31,21 @@ class TimeWindowParser def parse(string) case normalize(string) when "today" - now.beginning_of_day..now.end_of_day + now.all_day when "yesterday" - (now - 1.day).beginning_of_day..(now - 1.day).end_of_day + (now - 1.day).all_day when "thisweek" - now.beginning_of_week..now.end_of_week + now.all_week when "thismonth" - now.beginning_of_month..now.end_of_month + now.all_month when "thisyear" - now.beginning_of_year..now.end_of_year + now.all_year when "lastweek" - (now - 1.week).beginning_of_week..(now - 1.week).end_of_week + (now - 1.week).all_week when "lastmonth" - (now - 1.month).beginning_of_month..(now - 1.month).end_of_month + (now - 1.month).all_month when "lastyear" - (now - 1.year).beginning_of_year..(now - 1.year).end_of_year + (now - 1.year).all_year end end diff --git a/app/models/user.rb b/app/models/user.rb index 20ee1c40c..e16d70670 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,8 @@ class User < ApplicationRecord belongs_to :account belongs_to :identity, optional: true + validates :name, presence: true + has_many :comments, inverse_of: :creator, dependent: :destroy has_many :filters, foreign_key: :creator_id, inverse_of: :creator, dependent: :destroy @@ -14,8 +16,6 @@ class User < ApplicationRecord has_many :pinned_cards, through: :pins, source: :card has_many :exports, class_name: "Account::Export", dependent: :destroy - scope :with_avatars, -> { preload(:account, :avatar_attachment) } - def deactivate transaction do accesses.destroy_all diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index c635edddb..400738cb1 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -2,13 +2,20 @@ 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 included do has_one_attached :avatar do |attachable| attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately end - validate :avatar_content_type_allowed + scope :with_avatars, -> { preload(:account, :avatar_attachment) } + + validate :avatar_content_type_allowed, :avatar_dimensions_allowed, if: :avatar_attached? + end + + def avatar_attached? + avatar.attached? end def avatar_thumbnail @@ -17,8 +24,23 @@ module User::Avatar private def avatar_content_type_allowed - if avatar.attached? && !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) + if !ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type) errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP image") end end + + def avatar_dimensions_allowed + return unless avatar.blob.analyzed? || avatar.blob.analyze + + width = avatar.blob.metadata[:width] + height = avatar.blob.metadata[:height] + + if width && width > MAX_AVATAR_DIMENSIONS[:width] + errors.add(:avatar, "width must be less than #{MAX_AVATAR_DIMENSIONS[:width]}px") + end + + if height && height > MAX_AVATAR_DIMENSIONS[:height] + errors.add(:avatar, "height must be less than #{MAX_AVATAR_DIMENSIONS[:height]}px") + end + end end diff --git a/app/models/user/email_address_changeable.rb b/app/models/user/email_address_changeable.rb index 2c8de7217..79479f788 100644 --- a/app/models/user/email_address_changeable.rb +++ b/app/models/user/email_address_changeable.rb @@ -39,7 +39,7 @@ module User::EmailAddressChangeable private def generate_email_address_change_token(from: identity.email_address, to:, **options) - options = options.reverse_merge( + options = options.with_defaults( for: EMAIL_CHANGE_TOKEN_PURPOSE, old_email_address: from, new_email_address: to, diff --git a/app/models/user/named.rb b/app/models/user/named.rb index aa4f9b585..d052a79ad 100644 --- a/app/models/user/named.rb +++ b/app/models/user/named.rb @@ -19,7 +19,7 @@ module User::Named def familiar_name names = name.split - return name if names.length == 1 + return name if names.length <= 1 "#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}" end end diff --git a/app/views/account/settings/_user.html.erb b/app/views/account/settings/_user.html.erb index 6bc65acd8..2388c542d 100644 --- a/app/views/account/settings/_user.html.erb +++ b/app/views/account/settings/_user.html.erb @@ -1,4 +1,4 @@ -
  • +
  • <%= link_to user, class: "txt-ink flex gap-half align-center min-width" do %> <%= avatar_preview_tag user, hidden_for_screen_reader: true %>
    diff --git a/app/views/account/settings/_users.html.erb b/app/views/account/settings/_users.html.erb index 5e571eb67..63b5b1763 100644 --- a/app/views/account/settings/_users.html.erb +++ b/app/views/account/settings/_users.html.erb @@ -12,7 +12,7 @@
    -
      +
        <%= render partial: "account/settings/user", collection: users %>
    diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 273a07b9a..fc5e799b3 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -20,3 +20,5 @@ <%= render "account/settings/export" %>
    + +<%= render "account/settings/subscription_panel" if Fizzy.saas? %> diff --git a/app/views/active_storage/blobs/web/_representation.html.erb b/app/views/active_storage/blobs/web/_representation.html.erb index 2aa1b51a4..ca934f469 100644 --- a/app/views/active_storage/blobs/web/_representation.html.erb +++ b/app/views/active_storage/blobs/web/_representation.html.erb @@ -15,7 +15,7 @@ <% elsif blob.variable? %> - <%= link_to url_for(blob.variant(variant)), data: { action: "lightbox#open:prevent", lightbox_target: "image" } do %> + <%= link_to url_for(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %> <%= image_tag url_for(blob.variant(variant)), width: width, height: height %> <% end %> <% elsif blob.previewable? %> diff --git a/app/views/boards/columns/create.turbo_stream.erb b/app/views/boards/columns/create.turbo_stream.erb index 957547c09..10ce7df7a 100644 --- a/app/views/boards/columns/create.turbo_stream.erb +++ b/app/views/boards/columns/create.turbo_stream.erb @@ -1 +1,2 @@ <%= turbo_stream.before("closed-cards", partial: "boards/show/column", method: :morph, locals: { column: @column }) %> +<%= render "columns/refresh_adjacent_columns", column: @column %> diff --git a/app/views/boards/columns/destroy.turbo_stream.erb b/app/views/boards/columns/destroy.turbo_stream.erb deleted file mode 100644 index 7b6613a95..000000000 --- a/app/views/boards/columns/destroy.turbo_stream.erb +++ /dev/null @@ -1 +0,0 @@ -<%= turbo_stream.remove(dom_id(@column)) %> \ No newline at end of file diff --git a/app/views/cards/_card.json.jbuilder b/app/views/cards/_card.json.jbuilder index a1c509a66..e5792f127 100644 --- a/app/views/cards/_card.json.jbuilder +++ b/app/views/cards/_card.json.jbuilder @@ -6,6 +6,7 @@ json.cache! card do json.tags card.tags.pluck(:title).sort + json.closed card.closed? json.golden card.golden? json.last_active_at card.last_active_at.utc json.created_at card.created_at.utc diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb index 983f359ed..c85236683 100644 --- a/app/views/cards/_container.html.erb +++ b/app/views/cards/_container.html.erb @@ -1,5 +1,5 @@ -<% cache card do %> -
    +
    + <% cache card do %>
    <%= render "cards/container/gild", card: card if card.published? && !card.closed? %> <%= render "cards/container/image", card: card %> @@ -33,11 +33,15 @@ <%= render "cards/display/preview/bubble", card: card %> <% end %>
    + <% end %> - <% if card.published? %> - <%= render "cards/container/footer/published", card: card %> - <% elsif card.drafted? %> - <%= render "cards/container/footer/draft", card: card %> + <% if card.published? %> + <%= render "cards/container/footer/published", card: card %> + <% elsif card.drafted? %> + <% if Fizzy.saas? %> + <%= render "cards/container/footer/saas/create", card: card %> + <% else %> + <%= render "cards/container/footer/create", card: card %> <% end %> -
    -<% end %> + <% end %> +
    diff --git a/app/views/cards/assignments/new.html.erb b/app/views/cards/assignments/new.html.erb index 6692a53f0..609478550 100644 --- a/app/views/cards/assignments/new.html.erb +++ b/app/views/cards/assignments/new.html.erb @@ -1,10 +1,12 @@ <%= turbo_frame_tag @card, :assignment do %> <%= tag.div class: "max-width full-width", data: { action: "turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset", - controller: "filter navigable-list", + controller: "filter navigable-list assignment-limit", dialog_target: "dialog", navigable_list_focus_on_selection_value: false, - navigable_list_actionable_items_value: true } do %> + navigable_list_actionable_items_value: true, + assignment_limit_limit_value: Assignment::LIMIT, + assignment_limit_count_value: @card.assignments.count } do %>
    Assign this to… @@ -17,9 +19,18 @@ + + <% end %> <% end %> diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index 49ae74182..2f8a120a0 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -1,7 +1,8 @@ <% cache comment do %> - <%= turbo_frame_tag comment, :container do %> - <%# Bump for CSS changes: 2025-06-30 -%> -
    "> + <%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %> + <%= turbo_frame_tag comment, :container, class: ["comment--system": comment.creator.system?] do %> + <%# Cache bump 2025-12-14: action text attachment rendering changed for lightbox -%> +
    @@ -19,6 +20,11 @@ <% end %> + + <%= link_to edit_card_comment_path(comment.card, comment), class: "comment__edit btn btn--circle borderless translucent", data: { only_visible_to_you: true } do %> <%= icon_tag "menu-dots-horizontal" %> diff --git a/app/views/cards/container/footer/_create.html.erb b/app/views/cards/container/footer/_create.html.erb new file mode 100644 index 000000000..8b5062fdd --- /dev/null +++ b/app/views/cards/container/footer/_create.html.erb @@ -0,0 +1,19 @@ +
    +
    + <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", + title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })", + form: { data: { controller: "form" } }, + data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %> + Create card + <% end %> + + <%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another", + title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } }, + data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %> + Create and add another + <% end %> +
    + + <%= render "cards/container/footer/saas/near_notice" if Fizzy.saas? %> +
    + diff --git a/app/views/cards/container/footer/_draft.html.erb b/app/views/cards/container/footer/_draft.html.erb deleted file mode 100644 index 0dc6061a5..000000000 --- a/app/views/cards/container/footer/_draft.html.erb +++ /dev/null @@ -1,14 +0,0 @@ -
    - <%= button_to card_publish_path(card), name: "creation_type", value: "add", class: "btn", - title: "Create card (#{ hotkey_label(["ctrl", "enter"]) })", - form: { data: { controller: "form" } }, - data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %> - Create card - <% end %> - - <%= button_to card_publish_path(card), method: :post, class: "btn btn--reversed", name: "creation_type", value: "add_another", - title: "Create and add another (#{ hotkey_label(["ctrl", "shift", "enter"]) })", form: { data: { controller: "form" } }, - data: { form_target: "submit", controller: "clicker", action: "keydown.ctrl+shift+enter@document->clicker#click keydown.meta+shift+enter@document->clicker#click" } do %> - Create and add another - <% end %> -
    diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index c47ea7802..655652ffe 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -30,8 +30,9 @@
    -