diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..dba71e970 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +@../AGENTS.md diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index bbd1eb22a..d89381607 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -34,6 +34,9 @@ jobs: - runner: ubuntu-latest platform: linux/amd64 arch: amd64 + - runner: ubuntu-24.04-arm + platform: linux/arm64 + arch: arm64 env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} @@ -170,11 +173,13 @@ jobs: docker buildx imagetools create \ --tag "$tag" \ --annotation "index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}" \ - "${src_tag}-amd64" + "${src_tag}-amd64" \ + "${src_tag}-arm64" else docker buildx imagetools create \ --tag "$tag" \ - "${src_tag}-amd64" + "${src_tag}-amd64" \ + "${src_tag}-arm64" fi done <<< "$tags" diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c2d..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/Gemfile.lock b/Gemfile.lock index 0e48985b0..ea92feb39 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ GIT remote: https://github.com/basecamp/lexxy - revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 + revision: 4f0fc4d5773bc6892de70f175440c259974c12a7 specs: - lexxy (0.1.24.beta) + lexxy (0.7.0.beta) rails (>= 8.0.2) GIT @@ -147,7 +147,7 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.20.1) + bootsnap (1.21.1) msgpack (~> 1.2) brakeman (7.1.2) racc @@ -449,7 +449,7 @@ GEM timeout (0.6.0) trilogy (2.9.0) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.21) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f340b4ae4..6dcb7b9ee 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,9 +21,9 @@ GIT GIT remote: https://github.com/basecamp/lexxy - revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 + revision: 4f0fc4d5773bc6892de70f175440c259974c12a7 specs: - lexxy (0.1.24.beta) + lexxy (0.7.0.beta) rails (>= 8.0.2) GIT @@ -225,7 +225,7 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.20.1) + bootsnap (1.21.1) msgpack (~> 1.2) brakeman (7.1.2) racc @@ -568,7 +568,7 @@ GEM timeout (0.6.0) trilogy (2.9.0) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.21) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) diff --git a/app/assets/images/person.svg b/app/assets/images/person.svg index 1e29ed27d..b126d685d 100644 --- a/app/assets/images/person.svg +++ b/app/assets/images/person.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 2c0c67ef9..1083c2ffc 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -378,6 +378,7 @@ } .cards__menu { + position: relative; z-index: var(--z-popup); } @@ -535,16 +536,33 @@ /* Set lower limit for font size */ font-size: clamp(0.6rem, 0.85cqi, 100px); - .card__comments { - --column-gap: 0.8ch; + .card__counts { + --gap: 0.5ch; + align-items: flex-end; display: flex; - margin-block-end: calc(var(--block-space) * -1.7); - margin-inline-end: calc(var(--card-padding-inline) * -0.5); + flex-shrink: 0; + gap: calc(2 * var(--gap)); + margin-inline: auto calc(var(--card-padding-inline) * -0.5); + padding-inline-start: var(--gap); + } - .icon { - --icon-size: 1.6em; + .card__boosts, + .card__comments { + --icon-size: 1.6em; + align-items: center; + display: flex; + flex-shrink: 0; + font-weight: 600; + gap: var(--gap); + + img { + block-size: var(--icon-size); + inline-size: var(--icon-size); + } + + .icon--comment { color: var(--card-color); } } @@ -583,19 +601,36 @@ .local-time-value { font-weight: inherit; } + + @media (max-width: 639px) { + inline-size: auto; + } } - .card:has(.card__background img:not([src=""])) { + &:has(.card__background img:not([src=""])) { .card__content, .card__meta, + .card__boosts, + .card__comments, .card__column-name:not(.card__column-name--current) { opacity: 0; transition: opacity 0.2s ease-in-out; + } - @media (any-hover: hover) { - .card:hover & { + @media (any-hover: hover) { + &:hover { + .card__content, + .card__footer, + .card__boosts, + .card__comments, + .card__column-name:not(.card__column-name--current) { opacity: 1; } + + .card__background img { + filter: blur(3px) brightness(1.2); + opacity: 0.2; + } } } } @@ -604,13 +639,6 @@ inset-inline-start: 100%; translate: -90% -40%; } - - @media (any-hover: hover) { - .card:has(.card__background img:not([src=""])):hover .card__background img:not([src=""]) { - filter: blur(3px) brightness(1.2); - opacity: 0.2; - } - } } /* Considering diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 8d1340c23..b3d37d68a 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -25,7 +25,7 @@ position: relative; &:has(dialog[open]) { - z-index: 2; + z-index: 3; } &:has(.card-perma__star-input:checked) { @@ -34,6 +34,23 @@ } } + @media (max-width: 799px) { + --half-btn-height: 1.25rem; + --padding-inline: 1.5ch; + + column-gap: 0; + grid-template-areas: + "notch-top notch-top notch-top" + "card card card" + "actions-left notch-bottom actions-right" + "closure-message closure-message closure-message"; + grid-template-columns: 1fr auto 1fr; + inline-size: calc(100% + 2 * var(--padding-inline)); + margin-inline: calc(-1 * var(--padding-inline)); + max-inline-size: none; + position: relative; + } + .card { --card-aspect-ratio: 2 / 0.95; --lexxy-bg-color: var(--card-bg-color); @@ -47,6 +64,8 @@ } .card__body { + position: relative; + @media (max-width: 639px) { flex-direction: column; padding-block-end: calc(var(--card-padding-block) * 1.5); @@ -86,6 +105,11 @@ } } + .card__meta { + grid-area: meta; + margin-inline-end: auto; + } + .card__stages { max-inline-size: 32ch; @@ -119,15 +143,69 @@ } } - .board-picker__button { - --btn-border-size: 0; + .card__footer { + --btn-size: 2.5rem; - font-size: 0.5em; - margin-inline-start: 1ch; + display: flex; + gap: 0.5ch; + inline-size: 100%; + text-align: start; - + .panel { - --panel-size: 18ch; + /* Switch to grid layout so that the bg zoom button can stay next to the + * meta element, and the reactions can sit below */ + &:has(.reaction) { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: + "meta bg-zoom" + "reactions reactions"; } + + @media (max-width: 639px) { + font-size: var(--text-x-small); + } + } + + .reactions { + --reaction-size: var(--btn-size); + + align-self: flex-end; + display: flex; + gap: 0.5ch; + grid-area: reactions; + margin-inline-start: auto; + + &:has(.reaction) { + --padding: calc(var(--card-padding-block) / 2); + --reaction-size: 1.6875rem; + + margin-block: var(--padding) calc(-1 * var(--padding)); + padding-block-start: var(--padding); + position: relative; + + &:before { + border-block-start: 1px dashed color-mix(in srgb, transparent, var(--card-color) 33%); + content: ""; + inset: 0 calc(-1 * var(--card-padding-inline)) auto; + position: absolute; + } + } + + &:not(:has(.reaction)) { + position: static; + + .reactions__trigger { + --btn-border-color: var(--color-ink-light); + } + } + } + + .reaction__popup.popup { + inline-size: max-content; + } + + .card__zoom-bg-btn { + grid-area: bg-zoom; } .bubble { @@ -143,23 +221,6 @@ inset: calc(var(--bubble-size) / 1.5) 0 auto auto; } } - - @media (max-width: 799px) { - --half-btn-height: 1.5rem; - --padding-inline: 1.5ch; - - column-gap: 0; - grid-template-areas: - "notch-top notch-top notch-top" - "card card card" - "actions-left notch-bottom actions-right" - "closure-message closure-message closure-message"; - grid-template-columns: 1fr auto 1fr; - inline-size: calc(100% + 2 * var(--padding-inline)); - margin-inline: calc(-1 * var(--padding-inline)); - max-inline-size: none; - position: relative; - } } /* Child items @@ -292,10 +353,7 @@ /* Overlap the card BG by half the button height */ &:has(.btn) { - &, - ~ .card-perma__closure-message { - translate: 0 calc(-1 * var(--half-btn-height)); - } + translate: 0 calc(-1 * var(--half-btn-height)); } form { @@ -308,12 +366,6 @@ --btn-color: var(--color-ink-inverted); } - .btn--plain { - --btn-color: var(--card-color); - - text-decoration: underline; - } - .btn--reversed { --btn-background: var(--color-canvas); --btn-color: var(--card-color); @@ -341,11 +393,25 @@ .card-perma__closure-message { color: var(--card-color); grid-area: closure-message; - margin-block-start: 0.5ch; + margin-block: var(--block-space) var(--block-space-double); padding-inline: 1ch; - form { - display: inline; + .btn--plain { + --btn-color: var(--card-color); + + text-decoration: underline; + } + + @media (max-width: 799px) { + margin-block: var(--block-space-half); + translate: 0 calc(-0.5 * var(--half-btn-height)); + } + + @media (min-width: 800px) { + .card-perma__notch--bottom:has(.btn) ~ & { + margin-block: var(--block-space-half) var(--block-space); + translate: 0 calc(-0.5 * var(--half-btn-height)); + } } } } diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 3fb0a7f4d..cbaa66c91 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -62,6 +62,8 @@ } .card__board { + align-items: center; + align-self: start; background-color: var(--card-color); border-radius: var(--border-radius) 0 var(--border-radius) 0; color: var(--color-ink-inverted); @@ -71,21 +73,19 @@ min-inline-size: 0; padding-block: 0.25lh; padding-inline: var(--card-padding-inline) 1ch; + position: relative; + transition: background-color 100ms ease-out; - &:has(.board-picker__button) { - cursor: pointer; - transition: background-color 100ms ease-out; - + &:has(.btn) { @media (any-hover: hover) { &:hover { background-color: color-mix(in srgb, var(--card-color) 90%, var(--color-ink)); } } + } - &:has(.btn:focus-visible) { - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } + dialog { + inset-block-start: 100%; } } @@ -99,9 +99,11 @@ } .card__board-name { + align-items: center; border-inline-start: 1px solid color-mix(in hsl, transparent 75%, currentColor); color: currentColor; display: flex; + gap: 0.25ch; margin-inline-start: var(--card-header-space); max-inline-size: 100%; min-inline-size: 0; @@ -109,14 +111,59 @@ text-transform: uppercase; } + .card__board-picker-button { + inset: 0; + position: absolute; + } + .card__tags { --btn-color: var(--card-color); align-items: center; + align-self: stretch; color: var(--card-text-color); display: flex; - gap: var(--card-header-space); + gap: 0.5ch; min-inline-size: 0; + + [data-controller="dialog"] { + align-items: center; + align-self: stretch; + display: flex; + position: relative; + } + + .popup { + --panel-size: 18ch; + + inset-block-start: 100%; + } + } + + .card__tag-picker { + --panel-border-radius: 2em; + --panel-padding: 0.5em 0.7em; + --panel-size: max-content; + + inline-size: auto !important; + inset: 0 auto auto 0; + max-inline-size: var(--panel-size) !important; + position: absolute; + z-index: 2; + + &[open] { + display: flex; + } + + .input { + --input-padding: 0.2em 0.5em; + + inline-size: 18ch; + } + } + + .card__tag-picker-button { + font-size: 0.6em; } .card__tag { @@ -207,9 +254,10 @@ padding-block: var(--block-space-half); } - /* Meta + /* Footer /* ------------------------------------------------------------------------ */ + /* Card metadata */ .card__meta { --meta-spacer-block: 0.5ch; --meta-spacer-inline: 0.75ch; @@ -331,11 +379,12 @@ display: flex; flex-direction: column; font-weight: bold; - inset: auto 1ch 1ch auto; + inset: auto 0 -1lh auto; justify-content: center; max-inline-size: 25ch; min-inline-size: 16ch; padding: 1ch; + pointer-events: none; position: absolute; rotate: 5deg; transform-origin: top right; diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index 006cbb985..a8605503b 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -80,6 +80,11 @@ display: none !important; } } + + .reactions { + margin-block-start: var(--block-space-half); + margin-inline: calc(var(--column-gap) / -1); + } } .comment__author { diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/dialog.css index 5d6a7eb0e..1336cad9b 100644 --- a/app/assets/stylesheets/dialog.css +++ b/app/assets/stylesheets/dialog.css @@ -1,4 +1,9 @@ @layer components { + /* Prevent page scrolling when modal dialog is open */ + html:has(dialog:modal) { + overflow: hidden; + } + :is(.dialog) { border: 0; opacity: 0; @@ -35,4 +40,9 @@ } } } + + /* Ensure padding from viewport edges */ + .dialog.panel { + max-inline-size: calc(100vw - var(--inline-space-double) * 2); + } } diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/events.css index 73e446185..5601b1988 100644 --- a/app/assets/stylesheets/events.css +++ b/app/assets/stylesheets/events.css @@ -287,6 +287,7 @@ .event { --column-gap: 0.7ch; + --event-padding: 0.6em; background-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas)); border-radius: 0.2em; @@ -296,7 +297,7 @@ max-inline-size: 100%; min-inline-size: 0; overflow: clip; - padding: 0.6em; + padding: var(--event-padding); position: relative; z-index: 0; @@ -321,12 +322,15 @@ z-index: 0; } + .card__header { + inline-size: 100%; + margin-block-start: calc(-1 * var(--event-padding)); + } + .card__board { background-color: transparent; color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink)); font-size: 0.7em; - padding: 0; - translate: 0 -0.3em; } .card__board-name { @@ -373,16 +377,11 @@ } .event__icon { + color: var(--card-color); display: grid; - margin: -0.3em -0.3em auto auto; + margin-inline-start: auto; place-content: center; - - .icon { - background-color: var(--card-color); - block-size: 1em; - inline-size: 1em; - grid-area: 1 / 1; - } + translate: calc(var(--event-padding) / 2); } .event__timestamp { diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 21ee18301..c35f14fee 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -7,7 +7,8 @@ position: relative; overflow: visible; - figure.node--selected { + figure.node--selected, + div.node--selected { &:has(img) { img { outline: var(--focus-ring-size) solid var(--focus-ring-color); @@ -22,6 +23,31 @@ } } + .lexxy-content__table-wrapper { + margin: 0; + margin-block: 1ch; + overflow-x: auto; + } + + table { + th, td { + &.table-cell--selected { + background-color: var(--color-selected-light); + } + + &.lexxy-content__table-cell--selected { + background-color: var(--color-selected); + border-color: var(--color-selected-dark); + } + } + + &.lexxy-content__table--selection { + ::selection { + background: transparent; + } + } + } + /* Lexical uses the `lexxy-editor--empty` class even if you have a list * started but haven't added other characters. Here, we hide the placeholder * when you click the List button in the toolbar. */ @@ -101,6 +127,11 @@ /* Toolbar /* ------------------------------------------------------------------------ */ + /* TODO: Temporary - hide table button while we work on the styles */ + button[name="table"] { + display: none; + } + lexxy-toolbar { --lexxy-toolbar-icon-size: 1em; @@ -115,7 +146,7 @@ position: relative; position: sticky; inset-block-start: 0; - z-index: 1; + z-index: 2; } .lexxy-editor__toolbar-button { @@ -166,7 +197,7 @@ inset-inline-end: 0; padding: 4px; position: absolute; - z-index: 1; + z-index: 2; } .lexxy-editor__toolbar-spacer { @@ -188,7 +219,7 @@ font-size: var(--text-small); padding: var(--lexxy-dropdown-padding); position: absolute; - z-index: 1; + z-index: 2; button { block-size: var(--lexxy-dropdown-btn-size); @@ -288,7 +319,130 @@ } } - /* Prompt ,enu (@mentions, etc.) + .lexxy-table-handle-buttons { + --button-size: 2.5em; + + color: var(--color-ink-inverted); + display: none; + flex-direction: row; + font-size: var(--text-x-small); + gap: 0.25ch; + line-height: 1; + position: absolute; + transform: translate(-50%, -120%); + z-index: 1; + + &:has([open]) { + z-index: 4; + } + + .lexxy-table-control { + align-items: center; + background-color: var(--color-ink); + border-radius: 0.75ch; + display: flex; + flex-direction: row; + gap: 1ch; + padding: 2px; + white-space: nowrap; + + button, + summary { + aspect-ratio: 1 / 1; + align-items: center; + background-color: transparent; + border-radius: 0.5ch; + border: 0; + color: var(--color-ink-inverted); + cursor: pointer; + display: flex; + font-size: inherit; + font-weight: 700; + justify-content: center; + line-height: 1; + list-style: none; + min-block-size: var(--button-size); + min-inline-size: var(--button-size); + padding: 0; + user-select: none; + -webkit-user-select: none; + + &:hover { + background-color: var(--color-ink-darker); + } + + &:focus, + &:focus-visible { + background-color: var(--color-ink-darker); + outline: var(--focus-ring-size) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); + } + + svg { + block-size: 1em; + inline-size: 1em; + fill: currentColor; + } + + span { + display: none; + } + } + } + + .lexxy-table-control__more-menu { + gap: 0; + padding: 2px; + position: relative; + + summary { + &::-webkit-details-marker { + display: none; + } + } + + .lexxy-table-control__more-menu-details { + display: flex; + flex-direction: column; + gap: 0.25ch; + inset-block-start: 105%; + inset-inline-end: 0; + padding: 0; + position: absolute; + + .lexxy-table-control__more-menu-section { + background: var(--color-ink); + border-radius: 0.75ch; + display: flex; + flex-direction: column; + padding: 2px; + } + + button { + aspect-ratio: unset; + align-items: center; + flex-direction: row; + font-weight: normal; + gap: 1ch; + justify-content: flex-start; + padding: 0.5ch 2ch; + padding-inline-start: 1ch; + white-space: nowrap; + + svg { + block-size: 1.3em; + inline-size: 1.3em; + } + + span { + display: inline-block; + } + } + } + } + } + + /* Prompt menu (@mentions, etc.) /* ------------------------------------------------------------------------ */ .lexxy-prompt-menu { diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 96df0e00b..8226a5261 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -6,6 +6,8 @@ --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)); + --footer-height: 0; + -webkit-tap-highlight-color: transparent; .hide-on-native { @@ -28,11 +30,27 @@ } } + /* Card columns + /* ------------------------------------------------------------------------ */ + + .board-tools.card { + padding-block-start: 0; + } + /* Card perma /* ------------------------------------------------------------------------ */ .card-perma { margin-block-start: 0; + + &:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg { + padding-block: clamp(0.25rem, 2vw, var(--padding-block)); + } + } + + .card-perma__closure-message { + margin-block: var(--block-space); + translate: unset; } /* Search @@ -43,3 +61,25 @@ } } } + +[data-bridge-components~=form] { + [data-controller~=bridge--form] { + [data-bridge--form-target~=submit] { + display: none; + } + } +} + +[data-bridge-components~=overflow-menu] { + [data-controller~=bridge--overflow-menu] { + [data-bridge--overflow-menu-target~=item] { + display: none; + } + } +} + +[data-bridge-components~=buttons] { + [data-bridge--buttons-target~=button] { + display: none; + } +} diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index 02ae8969e..aa0b8c1da 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -21,11 +21,16 @@ display: var(--popup-display); } + /* The .pop-up--align- classes are used for initial alignment. + * The orient JS helper layers on the .orient- to avoid running + * off the edge of the screen and will override .popup--align */ + &:where(.popup--align-left), &.orient-left { inset-inline: auto 0; transform: translateX(0); } + &:where(.popup--align-right), &.orient-right { inset-inline: 0 auto; transform: translateX(0); @@ -48,6 +53,10 @@ .popup__title { font-weight: 800; white-space: nowrap; + + &[tabindex="-1"]:focus-visible { + outline: unset; + } } /* Hide lists when all the items within are hidden */ @@ -123,6 +132,10 @@ } } + &:has(.popup__btn[disabled]) { + pointer-events: none; + } + .checked { display: none; } @@ -155,7 +168,7 @@ max-inline-size: 100%; padding: var(--inline-space-half) var(--popup-item-padding-inline); text-align: start; - + &:focus-visible { z-index: 1; } diff --git a/app/assets/stylesheets/print.css b/app/assets/stylesheets/print.css index 5ed9c3fdf..cd55b5196 100644 --- a/app/assets/stylesheets/print.css +++ b/app/assets/stylesheets/print.css @@ -164,8 +164,8 @@ .comments__subscribers, .card__meta-avatars--assignees > div > div:last-child, .steps > .step:last-child, - div:has(> .tag-picker__button), - div:has(> .board-picker__button), + .card__board-name .icon, + div:has(> .card__tag-picker-button), .delete-card, .header--card .header__title { display: none; diff --git a/app/assets/stylesheets/reactions.css b/app/assets/stylesheets/reactions.css index d1cd0a03f..64f2a5164 100644 --- a/app/assets/stylesheets/reactions.css +++ b/app/assets/stylesheets/reactions.css @@ -10,8 +10,6 @@ flex-wrap: wrap; gap: var(--inline-space-half); inline-size: 100%; - margin-block-start: var(--block-space-half); - margin-inline: calc(var(--column-gap) / -1); &:has([open]) { z-index: var(--z-popup); @@ -34,11 +32,8 @@ .reactions__trigger { --btn-border-color: var(--color-ink-lightest); - background-color: var(--color-ink-lightest); - &:not(:hover) { - opacity: 0.66; - } + background-color: var(--color-ink-lightest); } } } diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 88e4d2ae2..bf5328478 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -209,6 +209,45 @@ } } } + + /* Tables */ + .lexxy-content__table-wrapper { + margin: 0; + margin-block: 1ch; + overflow-x: auto; + } + + table { + border-collapse: collapse; + border-spacing: 0; + inline-size: calc(100% - 0.5ch); + margin: 0.25ch; + + th, + td { + border: 1px solid var(--color-ink-light); + padding: 0.5ch 1ch; + text-align: start; + word-break: normal; + + *:last-child { + margin-block-end: 0; + } + + *:not(pre, code) { + word-break: normal; + } + } + + th, + .lexxy-content__table-cell--header { + background: var(--color-ink-lightest); + } + + td { + background: var(--color-canvas); + } + } } /* Attachments diff --git a/app/assets/stylesheets/tags.css b/app/assets/stylesheets/tags.css deleted file mode 100644 index 90d51e011..000000000 --- a/app/assets/stylesheets/tags.css +++ /dev/null @@ -1,33 +0,0 @@ -@layer components { - .tag-picker { - --panel-border-radius: 2em; - --panel-padding: 0.5em 0.7em; - --panel-size: max-content; - - inline-size: auto !important; - inset: 0 auto auto 0; - max-inline-size: var(--panel-size) !important; - position: absolute; - z-index: 2; - - &[open] { - display: flex; - } - - .input { - --input-padding: 0.2em 0.5em; - - inline-size: 18ch; - } - } - - .tag-picker__button { - font-size: 0.6em; - margin-block-start: 0.1em; - margin-inline-start: 0.2em; - - + .panel { - --panel-size: 18ch; - } - } -} diff --git a/app/assets/stylesheets/trays.css b/app/assets/stylesheets/trays.css index 7793b6f2d..0e47857ab 100644 --- a/app/assets/stylesheets/trays.css +++ b/app/assets/stylesheets/trays.css @@ -440,6 +440,7 @@ .card__meta-text:not(.card__meta-text--updated), .card__stages, .card__steps, + .card__boosts, .card__comments, .card__closed { display: none; diff --git a/app/controllers/cards/comments/reactions_controller.rb b/app/controllers/cards/comments/reactions_controller.rb index 0f1277a3c..0c822360e 100644 --- a/app/controllers/cards/comments/reactions_controller.rb +++ b/app/controllers/cards/comments/reactions_controller.rb @@ -2,20 +2,26 @@ class Cards::Comments::ReactionsController < ApplicationController include CardScoped before_action :set_comment - before_action :set_reaction, only: %i[ destroy ] - before_action :ensure_permision_to_administer_reaction, only: %i[ destroy ] + before_action :set_reactable + + with_options only: :destroy do + before_action :set_reaction + before_action :ensure_permission_to_administer_reaction + end def index + render "reactions/index" end def new + render "reactions/new" end def create - @reaction = @comment.reactions.create!(params.expect(reaction: :content)) + @reaction = @reactable.reactions.create!(params.expect(reaction: :content)) respond_to do |format| - format.turbo_stream + format.turbo_stream { render "reactions/create" } format.json { head :created } end end @@ -24,7 +30,7 @@ class Cards::Comments::ReactionsController < ApplicationController @reaction.destroy respond_to do |format| - format.turbo_stream + format.turbo_stream { render "reactions/destroy" } format.json { head :no_content } end end @@ -34,11 +40,15 @@ class Cards::Comments::ReactionsController < ApplicationController @comment = @card.comments.find(params[:comment_id]) end - def set_reaction - @reaction = @comment.reactions.find(params[:id]) + def set_reactable + @reactable = @comment end - def ensure_permision_to_administer_reaction + def set_reaction + @reaction = @reactable.reactions.find(params[:id]) + end + + def ensure_permission_to_administer_reaction head :forbidden if Current.user != @reaction.reacter end end diff --git a/app/controllers/cards/comments_controller.rb b/app/controllers/cards/comments_controller.rb index 27c507abe..f2b318813 100644 --- a/app/controllers/cards/comments_controller.rb +++ b/app/controllers/cards/comments_controller.rb @@ -3,6 +3,7 @@ class Cards::CommentsController < ApplicationController before_action :set_comment, only: %i[ show edit update destroy ] before_action :ensure_creatorship, only: %i[ edit update destroy ] + before_action :ensure_card_is_commentable, only: :create def index set_page_and_extract_portion_from @card.comments.chronologically @@ -50,6 +51,10 @@ class Cards::CommentsController < ApplicationController head :forbidden if Current.user != @comment.creator end + def ensure_card_is_commentable + head :forbidden unless @card.commentable? + end + def comment_params params.expect(comment: [ :body, :created_at ]) end diff --git a/app/controllers/cards/reactions_controller.rb b/app/controllers/cards/reactions_controller.rb new file mode 100644 index 000000000..4105a4121 --- /dev/null +++ b/app/controllers/cards/reactions_controller.rb @@ -0,0 +1,49 @@ +class Cards::ReactionsController < ApplicationController + include CardScoped + + before_action :set_reactable + + with_options only: :destroy do + before_action :set_reaction + before_action :ensure_permission_to_administer_reaction + end + + def index + render "reactions/index" + end + + def new + render "reactions/new" + end + + def create + @reaction = @reactable.reactions.create!(params.expect(reaction: :content)) + + respond_to do |format| + format.turbo_stream { render "reactions/create" } + format.json { head :created } + end + end + + def destroy + @reaction.destroy + + respond_to do |format| + format.turbo_stream { render "reactions/destroy" } + format.json { head :no_content } + end + end + + private + def set_reactable + @reactable = @card + end + + def set_reaction + @reaction = @reactable.reactions.find(params[:id]) + end + + def ensure_permission_to_administer_reaction + head :forbidden if Current.user != @reaction.reacter + end +end diff --git a/app/controllers/cards/self_assignments_controller.rb b/app/controllers/cards/self_assignments_controller.rb new file mode 100644 index 000000000..a38c99cf0 --- /dev/null +++ b/app/controllers/cards/self_assignments_controller.rb @@ -0,0 +1,17 @@ +class Cards::SelfAssignmentsController < ApplicationController + include CardScoped + + def create + if @card.toggle_assignment(Current.user) + respond_to do |format| + format.turbo_stream { render "cards/assignments/create" } + format.json { head :no_content } + end + else + respond_to do |format| + format.turbo_stream { render "cards/assignments/create" } + format.json { head :unprocessable_entity } + end + end + end +end diff --git a/app/controllers/webhooks/activations_controller.rb b/app/controllers/webhooks/activations_controller.rb index cbe3e9e32..82166043e 100644 --- a/app/controllers/webhooks/activations_controller.rb +++ b/app/controllers/webhooks/activations_controller.rb @@ -1,8 +1,10 @@ class Webhooks::ActivationsController < ApplicationController + include BoardScoped + before_action :ensure_admin def create - webhook = Current.account.webhooks.find(params[:webhook_id]) + webhook = @board.webhooks.find(params[:webhook_id]) webhook.activate redirect_to webhook diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb index 1ace03574..c5a4ae1c6 100644 --- a/app/helpers/accesses_helper.rb +++ b/app/helpers/accesses_helper.rb @@ -48,7 +48,8 @@ module AccessesHelper params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only }, aria: { labelledby: dom_id(board, :involvement_label) }, title: (label_text if icon_only), - class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do + class: class_names("btn", { "btn--reversed": access.watching? && icon_only }), + data: !icon_only && { bridge__overflow_menu_target: "item", bridge_title: label_text }) do icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") + tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label)) end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index a1960b341..9b9ee89b3 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -4,7 +4,8 @@ module BoardsHelper end def link_to_edit_board(board) - link_to edit_board_path(board), class: "btn", data: { controller: "tooltip" } do + link_to edit_board_path(board), class: "btn", + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Board settings" } do icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader") end end diff --git a/app/helpers/bridge_helper.rb b/app/helpers/bridge_helper.rb new file mode 100644 index 000000000..5fb4508b6 --- /dev/null +++ b/app/helpers/bridge_helper.rb @@ -0,0 +1,5 @@ +module BridgeHelper + def bridge_icon(name) + asset_url("#{name}.svg") + end +end diff --git a/app/helpers/entropy_helper.rb b/app/helpers/entropy_helper.rb index f1a50bbe9..4329dab88 100644 --- a/app/helpers/entropy_helper.rb +++ b/app/helpers/entropy_helper.rb @@ -16,6 +16,7 @@ module EntropyHelper { stalledAfterDays: card.entropy.days_before_reminder, lastActivitySpikeAt: card.last_activity_spike_at.iso8601, + updatedAt: card.updated_at.iso8601, action: "Stalled" } end diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb index 6c3f01725..a0529b11a 100644 --- a/app/helpers/filters_helper.rb +++ b/app/helpers/filters_helper.rb @@ -40,6 +40,10 @@ module FiltersHelper }, &block end + def filter_title(title) + tag.strong title, class: "popup__title pad-inline-half", tabindex: "-1", data: { dialog_target: "focusTouch" } + end + def collapsible_nav_section(title, **properties, &block) tag.details class: "nav__section popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do concat(tag.summary(class: "popup__section-title") do diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 095655e3b..2902f809d 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -9,4 +9,23 @@ module FormsHelper form_with(**attributes, data: data) { } end end + + def bridged_form_with(**attributes, &) + data = attributes.delete(:data) || {} + controllers = [ data[:controller], "bridge--form" ].compact.join(" ").strip + actions = [ + data[:action], + "turbo:submit-start->bridge--form#submitStart", + "turbo:submit-end->bridge--form#submitEnd" + ].compact.join(" ").strip + + data[:controller] = controllers + data[:action] = actions + + if block_given? + form_with **attributes, data: data, & + else + form_with(**attributes, data: data) { } + end + end end diff --git a/app/helpers/reactions_helper.rb b/app/helpers/reactions_helper.rb new file mode 100644 index 000000000..5c47781f6 --- /dev/null +++ b/app/helpers/reactions_helper.rb @@ -0,0 +1,10 @@ +module ReactionsHelper + def reaction_path_prefix_for(reactable) + case reactable + when Card then [ reactable ] + when Comment then [ reactable.card, reactable ] + else + raise ArgumentError, "Unknown reactable type: #{reactable.class}" + end + end +end diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb index f3bb077c8..a2e14af29 100644 --- a/app/helpers/webhooks_helper.rb +++ b/app/helpers/webhooks_helper.rb @@ -25,7 +25,7 @@ module WebhooksHelper def link_to_webhooks(board, &) link_to board_webhooks_path(board_id: board), class: [ "btn", { "btn--reversed": board.webhooks.any? } ], - data: { controller: "tooltip" } do + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Webhooks" } do icon_tag("world") + tag.span("Webhooks", class: "for-screen-reader") end end diff --git a/app/javascript/application.js b/app/javascript/application.js index bdafb95b0..c43a45eaa 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,8 +1,8 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" +import "@hotwired/hotwire-native-bridge" import "initializers" import "controllers" import "lexxy" import "@rails/actiontext" - diff --git a/app/javascript/controllers/bridge/buttons_controller.js b/app/javascript/controllers/bridge/buttons_controller.js new file mode 100644 index 000000000..47d09a158 --- /dev/null +++ b/app/javascript/controllers/bridge/buttons_controller.js @@ -0,0 +1,43 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "buttons" + static targets = [ "button" ] + + buttonTargetConnected() { + this.notifyBridgeOfConnect() + } + + buttonTargetDisconnected() { + if (!this.#isControllerTearingDown()) { + this.notifyBridgeOfConnect() + } + } + + notifyBridgeOfConnect() { + const buttons = this.#enabledButtonTargets + .map((target, index) => { + const element = new BridgeElement(target) + return { ...element.getButton(), index } + }) + + this.send("connect", { buttons }, message => { + this.#clickButton(message) + }) + } + + #clickButton(message) { + const selectedIndex = message.data.selectedIndex + this.#enabledButtonTargets[selectedIndex].click() + } + + get #enabledButtonTargets() { + return this.buttonTargets + .filter(target => !target.closest("[data-bridge-disabled]")) + } + + #isControllerTearingDown() { + return !document.body.contains(this.element) + } +} diff --git a/app/javascript/controllers/bridge/form_controller.js b/app/javascript/controllers/bridge/form_controller.js new file mode 100644 index 000000000..a3c780e62 --- /dev/null +++ b/app/javascript/controllers/bridge/form_controller.js @@ -0,0 +1,62 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "form" + static targets = [ "submit", "cancel" ] + static values = { submitTitle: String } + + submitTargetConnected() { + this.notifyBridgeOfConnect() + this.#observeSubmitTarget() + } + + submitTargetDisconnected() { + this.notifyBridgeOfDisonnect() + this.submitObserver?.disconnect() + } + + notifyBridgeOfConnect() { + const submitElement = new BridgeElement(this.submitTarget) + const cancelElement = this.hasCancelTarget ? new BridgeElement(this.cancelTarget) : null + + const submitButton = { title: submitElement.title } + const cancelButton = cancelElement ? { title: cancelElement.title } : null + + this.send("connect", { submitButton, cancelButton }, message => this.receive(message)) + } + + receive(message) { + switch (message.event) { + case "submit": + this.submitTarget.click() + break + case "cancel": + this.cancelTarget.click() + break + } + } + + notifyBridgeOfDisonnect() { + this.send("disconnect") + } + + submitStart() { + this.send("submitStart") + } + + submitEnd() { + this.send("submitEnd") + } + + #observeSubmitTarget() { + this.submitObserver = new MutationObserver(() => { + this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled") + }) + + this.submitObserver.observe(this.submitTarget, { + attributes: true, + attributeFilter: [ "disabled" ] + }) + } +} diff --git a/app/javascript/controllers/bridge/insets_controller.js b/app/javascript/controllers/bridge/insets_controller.js new file mode 100644 index 000000000..1cef15690 --- /dev/null +++ b/app/javascript/controllers/bridge/insets_controller.js @@ -0,0 +1,31 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +// Bridge component to control custom safe-area insets from native apps. +// Sets CSS variables --injected-safe-inset-(top|right|bottom|left). +export default class extends BridgeComponent { + static component = "insets" + + connect() { + super.connect() + this.notifyBridgeOfConnect() + } + + disconnect() { + super.disconnect() + this.send("disconnect") + } + + notifyBridgeOfConnect() { + this.send("connect", {}, message => { + this.#setInsets(message.data) + }) + } + + #setInsets({ top, right, bottom, left }) { + const root = document.documentElement.style + root.setProperty("--injected-safe-inset-top", `${top}px`) + root.setProperty("--injected-safe-inset-right", `${right}px`) + root.setProperty("--injected-safe-inset-bottom", `${bottom}px`) + root.setProperty("--injected-safe-inset-left", `${left}px`) + } +} diff --git a/app/javascript/controllers/bridge/overflow_menu_controller.js b/app/javascript/controllers/bridge/overflow_menu_controller.js new file mode 100644 index 000000000..ba2045dc6 --- /dev/null +++ b/app/javascript/controllers/bridge/overflow_menu_controller.js @@ -0,0 +1,43 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "overflow-menu" + static targets = [ "item" ] + + itemTargetConnected() { + this.notifyBridgeOfConnect() + } + + itemTargetDisconnected() { + if (!this.#isControllerTearingDown) { + this.notifyBridgeOfConnect() + } + } + + notifyBridgeOfConnect() { + const items = this.#enabledItemTargets + .map((target, index) => { + const element = new BridgeElement(target) + return { title: element.title, index } + }) + + this.send("connect", { items }, message => { + this.#clickItem(message) + }) + } + + #clickItem(message) { + const selectedIndex = message.data.selectedIndex + this.#enabledItemTargets[selectedIndex].click() + } + + get #enabledItemTargets() { + return this.itemTargets + .filter(target => !target.closest("[data-bridge-disabled]")) + } + + #isControllerTearingDown() { + return !document.body.contains(this.element) + } +} diff --git a/app/javascript/controllers/bridge/text_size_controller.js b/app/javascript/controllers/bridge/text_size_controller.js new file mode 100644 index 000000000..a464b8d45 --- /dev/null +++ b/app/javascript/controllers/bridge/text_size_controller.js @@ -0,0 +1,25 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "text-size" + + connect() { + super.connect() + this.notifyBridgeOfConnect() + } + + disconnect() { + super.disconnect() + this.send("disconnect") + } + + notifyBridgeOfConnect() { + this.send("connect", {}, message => { + this.#setTextSize(message.data) + }) + } + + #setTextSize(data) { + document.documentElement.dataset.textSize = data.textSize + } +} diff --git a/app/javascript/controllers/bridge/title_controller.js b/app/javascript/controllers/bridge/title_controller.js new file mode 100644 index 000000000..f2226be35 --- /dev/null +++ b/app/javascript/controllers/bridge/title_controller.js @@ -0,0 +1,63 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { viewport } from "helpers/bridge/viewport_helpers" +import { nextFrame } from "helpers/timing_helpers" + +export default class extends BridgeComponent { + static component = "title" + static targets = [ "header" ] + static values = { title: String } + + async connect() { + super.connect() + await nextFrame() + this.#startObserver() + window.addEventListener("resize", this.#windowResized) + } + + disconnect() { + super.disconnect() + this.#stopObserver() + window.removeEventListener("resize", this.#windowResized) + } + + notifyBridgeOfVisibilityChange(visible) { + this.send("visibility", { title: this.#title, elementVisible: visible }) + } + + // Intersection Observer + + #startObserver() { + if (!this.hasHeaderTarget) return + + this.observer = new IntersectionObserver(([ entry ]) => + this.notifyBridgeOfVisibilityChange(entry.isIntersecting), + { rootMargin: `-${this.#topOffset}px 0px 0px 0px` } + ) + + this.observer.observe(this.headerTarget) + this.previousTopOffset = this.#topOffset + } + + #stopObserver() { + this.observer?.disconnect() + } + + #updateObserverIfNeeded() { + if (this.#topOffset === this.previousTopOffset) return + + this.#stopObserver() + this.#startObserver() + } + + #windowResized = () => { + this.#updateObserverIfNeeded() + } + + get #title() { + return this.titleValue ? this.titleValue : document.title + } + + get #topOffset() { + return viewport.top + } +} diff --git a/app/javascript/controllers/bubble_controller.js b/app/javascript/controllers/bubble_controller.js index 714977c66..b8b7f6545 100644 --- a/app/javascript/controllers/bubble_controller.js +++ b/app/javascript/controllers/bubble_controller.js @@ -52,8 +52,11 @@ export default class extends Controller { this.#show() } + // Keep in sync with Card::Stallable#stalled? in app/models/card/stallable.rb get #isStalled() { - return this.stalledValue.lastActivitySpikeAt && signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()) > this.stalledValue.stalledAfterDays + return this.stalledValue.lastActivitySpikeAt && + signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()) > this.stalledValue.stalledAfterDays && + signedDifferenceInDays(new Date(this.stalledValue.updatedAt), new Date()) > this.stalledValue.stalledAfterDays } #showStalled() { diff --git a/app/javascript/controllers/card_hotkeys_controller.js b/app/javascript/controllers/card_hotkeys_controller.js index 564c076ad..62d02288d 100644 --- a/app/javascript/controllers/card_hotkeys_controller.js +++ b/app/javascript/controllers/card_hotkeys_controller.js @@ -79,7 +79,7 @@ export default class extends Controller { const selection = this.#selectedCard if (!selection) return - const url = selection.card.dataset.cardAssignToMeUrl + const url = selection.card.dataset.cardAssignToSelfUrl if (url) { event.preventDefault() await post(url, { responseKind: "turbo-stream" }) diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index f6449cea7..037c5ec7b 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -1,8 +1,9 @@ import { Controller } from "@hotwired/stimulus" import { orient } from "helpers/orientation_helpers" +import { isTouchDevice } from "helpers/platform_helpers" export default class extends Controller { - static targets = [ "dialog" ] + static targets = [ "dialog", "focusMouse", "focusTouch" ] static values = { modal: { type: Boolean, default: false }, sizing: { type: Boolean, default: true }, @@ -14,6 +15,10 @@ export default class extends Controller { if (this.autoOpenValue) this.open() } + focusTouchTargetConnected() { + this.#setupFocus() + } + open() { const modal = this.modalValue @@ -63,4 +68,10 @@ export default class extends Controller { captureKey(event) { if (event.key !== "Escape") { event.stopPropagation() } } + + #setupFocus() { + const touch = isTouchDevice() + if (this.hasFocusMouseTarget) this.focusMouseTarget.autofocus = !touch + if (this.hasFocusTouchTarget) this.focusTouchTarget.autofocus = touch + } } diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js index beca1f08f..2ba58ab89 100644 --- a/app/javascript/controllers/form_controller.js +++ b/app/javascript/controllers/form_controller.js @@ -54,6 +54,10 @@ export default class extends Controller { } submitToTopTarget(event) { + const value = event.target.value?.trim() + + if (!value) return false + this.element.setAttribute("data-turbo-frame", "_top") this.submit() } diff --git a/app/javascript/controllers/notifications_controller.js b/app/javascript/controllers/notifications_controller.js index 02fce86d7..7104cfa8b 100644 --- a/app/javascript/controllers/notifications_controller.js +++ b/app/javascript/controllers/notifications_controller.js @@ -11,8 +11,7 @@ export default class extends Controller { switch(Notification.permission) { case "default": - this.subscribeButtonTarget.hidden = false - this.explainerTarget.hidden = true + this.#showButtonToSubscribe() break case "granted": const registration = await this.#getServiceWorkerRegistration() @@ -20,6 +19,8 @@ export default class extends Controller { if (registration && subscription) { this.element.classList.add(this.enabledClass) + } else { + this.#showButtonToSubscribe() } break } @@ -54,8 +55,9 @@ export default class extends Controller { return navigator.serviceWorker.getRegistration("/service-worker.js", { scope: "/" }) } - #registerServiceWorker() { - return navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) + async #registerServiceWorker() { + await navigator.serviceWorker.register("/service-worker.js", { scope: "/" }) + return navigator.serviceWorker.ready } async #subscribe(registration) { @@ -76,6 +78,11 @@ export default class extends Controller { } } + #showButtonToSubscribe() { + this.subscribeButtonTarget.hidden = false + this.explainerTarget.hidden = true + } + async #requestPermissionAndSubscribe(registration) { const permission = await Notification.requestPermission() if (permission === "granted") this.#subscribe(registration) diff --git a/app/javascript/helpers/bridge/viewport_helpers.js b/app/javascript/helpers/bridge/viewport_helpers.js new file mode 100644 index 000000000..559143607 --- /dev/null +++ b/app/javascript/helpers/bridge/viewport_helpers.js @@ -0,0 +1,24 @@ +let top = 0 +const viewportTarget = window.visualViewport || window + +export const viewport = { + get top() { + return top + }, + get height() { + return viewportTarget.height || window.innerHeight + } +} + +function update() { + requestAnimationFrame(() => { + const styles = getComputedStyle(document.documentElement) + const customInset = styles.getPropertyValue("--custom-safe-inset-top") + const fallbackInset = styles.getPropertyValue("--safe-area-inset-top") + const insetValue = (customInset || fallbackInset).trim() + top = parseInt(insetValue || "0", 10) || 0 + }) +} + +viewportTarget.addEventListener("resize", update) +update() diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js new file mode 100644 index 000000000..7ddeb1b33 --- /dev/null +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -0,0 +1,18 @@ +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +BridgeElement.prototype.getButton = function() { + return { + title: this.title, + icon: this.getIcon() + } +} + +BridgeElement.prototype.getIcon = function() { + const url = this.bridgeAttribute(`icon-url`) + + if (url) { + return { url } + } + + return null +} diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js index 10fb36975..90ef36a26 100644 --- a/app/javascript/initializers/index.js +++ b/app/javascript/initializers/index.js @@ -1 +1,2 @@ import "initializers/current" +import "initializers/bridge/bridge_element" diff --git a/app/jobs/card/clean_inaccessible_data_job.rb b/app/jobs/card/clean_inaccessible_data_job.rb new file mode 100644 index 000000000..c3fbef936 --- /dev/null +++ b/app/jobs/card/clean_inaccessible_data_job.rb @@ -0,0 +1,7 @@ +class Card::CleanInaccessibleDataJob < ApplicationJob + discard_on ActiveJob::DeserializationError + + def perform(card) + card.clean_inaccessible_data + end +end diff --git a/app/models/card.rb b/app/models/card.rb index 18957cb9b..d422afa85 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,13 +1,13 @@ class Card < ApplicationRecord - include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, - Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, + include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Commentable, + Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } belongs_to :board belongs_to :creator, class_name: "User", default: -> { Current.user } - has_many :comments, dependent: :destroy + has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all has_one_attached :image, dependent: :purge_later has_rich_text :description @@ -23,7 +23,7 @@ class Card < ApplicationRecord scope :chronologically, -> { order created_at: :asc, id: :asc } scope :latest, -> { order last_active_at: :desc, id: :desc } scope :with_users, -> { preload(creator: [ :avatar_attachment, :account ], assignees: [ :avatar_attachment, :account ]) } - scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds } + scope :preloaded, -> { with_users.preload(:column, :tags, :steps, :closure, :goldness, :activity_spike, :image_attachment, reactions: :reacter, board: [ :entropy, :columns ], not_now: [ :user ]).with_rich_text_description_and_embeds } scope :indexed_by, ->(index) do case index @@ -46,12 +46,6 @@ class Card < ApplicationRecord end end - delegate :accessible_to?, to: :board - - def publicly_accessible? - published? && board.publicly_accessible? - end - def card self end @@ -72,55 +66,6 @@ class Card < ApplicationRecord end private - STORAGE_BATCH_SIZE = 1000 - - # Override to include comments, but only load comments that have attachments. - # Cards can have thousands of comments; most won't have attachments. - # Streams in batches to avoid loading all IDs into memory at once. - def storage_transfer_records - comment_ids_with_attachments = storage_comment_ids_with_attachments - - if comment_ids_with_attachments.any? - [ self, *comments.where(id: comment_ids_with_attachments).to_a ] - else - [ self ] - end - end - - def storage_comment_ids_with_attachments - direct = [] - rich_text_map = {} - - # Stream comment IDs in batches to avoid loading all into memory - comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch| - batch_ids = batch.pluck(:id) - - direct.concat \ - ActiveStorage::Attachment - .where(record_type: "Comment", record_id: batch_ids) - .distinct - .pluck(:record_id) - - ActionText::RichText - .where(record_type: "Comment", record_id: batch_ids) - .pluck(:id, :record_id) - .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id } - end - - embed_comment_ids = if rich_text_map.any? - rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids| - ActiveStorage::Attachment - .where(record_type: "ActionText::RichText", record_id: batch_ids) - .distinct - .pluck(:record_id) - end.filter_map { |rt_id| rich_text_map[rt_id] } - else - [] - end - - (direct + embed_comment_ids).uniq - end - def set_default_title self.title = "Untitled" if title.blank? end @@ -135,16 +80,13 @@ class Card < ApplicationRecord end remove_inaccessible_notifications_later + clean_inaccessible_data_later end def track_board_change_event(old_board_name) track_event "board_changed", particulars: { old_board: old_board_name, new_board: board.name } end - def grant_access_to_assignees - board.accesses.grant_to(assignees) - end - def assign_number self.number ||= account.increment!(:cards_count).cards_count end diff --git a/app/models/card/accessible.rb b/app/models/card/accessible.rb new file mode 100644 index 000000000..e74e0b914 --- /dev/null +++ b/app/models/card/accessible.rb @@ -0,0 +1,26 @@ +module Card::Accessible + extend ActiveSupport::Concern + + included do + delegate :accessible_to?, to: :board + end + + def publicly_accessible? + published? && board.publicly_accessible? + end + + def clean_inaccessible_data + accessible_user_ids = board.accesses.pluck(:user_id) + pins.where.not(user_id: accessible_user_ids).in_batches.destroy_all + watches.where.not(user_id: accessible_user_ids).in_batches.destroy_all + end + + private + def grant_access_to_assignees + board.accesses.grant_to(assignees) + end + + def clean_inaccessible_data_later + Card::CleanInaccessibleDataJob.perform_later(self) + end +end diff --git a/app/models/card/commentable.rb b/app/models/card/commentable.rb new file mode 100644 index 000000000..4296a77a7 --- /dev/null +++ b/app/models/card/commentable.rb @@ -0,0 +1,61 @@ +module Card::Commentable + extend ActiveSupport::Concern + + included do + has_many :comments, dependent: :destroy + end + + def commentable? + published? + end + + private + STORAGE_BATCH_SIZE = 1000 + + # Override to include comments, but only load comments that have attachments. + # Cards can have thousands of comments; most won't have attachments. + # Streams in batches to avoid loading all IDs into memory at once. + def storage_transfer_records + comment_ids_with_attachments = storage_comment_ids_with_attachments + + if comment_ids_with_attachments.any? + [ self, *comments.where(id: comment_ids_with_attachments).to_a ] + else + [ self ] + end + end + + def storage_comment_ids_with_attachments + direct = [] + rich_text_map = {} + + # Stream comment IDs in batches to avoid loading all into memory + comments.in_batches(of: STORAGE_BATCH_SIZE) do |batch| + batch_ids = batch.pluck(:id) + + direct.concat \ + ActiveStorage::Attachment + .where(record_type: "Comment", record_id: batch_ids) + .distinct + .pluck(:record_id) + + ActionText::RichText + .where(record_type: "Comment", record_id: batch_ids) + .pluck(:id, :record_id) + .each { |rt_id, comment_id| rich_text_map[rt_id] = comment_id } + end + + embed_comment_ids = if rich_text_map.any? + rich_text_map.keys.each_slice(STORAGE_BATCH_SIZE).flat_map do |batch_ids| + ActiveStorage::Attachment + .where(record_type: "ActionText::RichText", record_id: batch_ids) + .distinct + .pluck(:record_id) + end.filter_map { |rt_id| rich_text_map[rt_id] } + else + [] + end + + (direct + embed_comment_ids).uniq + end +end diff --git a/app/models/card/searchable.rb b/app/models/card/searchable.rb index 8cc108b23..d5f53205d 100644 --- a/app/models/card/searchable.rb +++ b/app/models/card/searchable.rb @@ -25,4 +25,8 @@ module Card::Searchable def search_board_id board_id end + + def searchable? + published? + end end diff --git a/app/models/card/stallable.rb b/app/models/card/stallable.rb index 03348dfde..9e82b3fa1 100644 --- a/app/models/card/stallable.rb +++ b/app/models/card/stallable.rb @@ -13,6 +13,7 @@ module Card::Stallable after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes? end + # Keep in sync with #isStalled in app/javascript/controllers/bubble_controller.js def stalled? if activity_spike.present? open? && last_activity_spike_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago && updated_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago diff --git a/app/models/comment.rb b/app/models/comment.rb index 30706e8b5..70a36562f 100644 --- a/app/models/comment.rb +++ b/app/models/comment.rb @@ -4,10 +4,12 @@ class Comment < ApplicationRecord belongs_to :account, default: -> { card.account } belongs_to :card, touch: true belongs_to :creator, class_name: "User", default: -> { Current.user } - has_many :reactions, -> { order(:created_at) }, dependent: :delete_all + has_many :reactions, -> { order(:created_at) }, as: :reactable, dependent: :delete_all has_rich_text :body + validate :card_is_commentable + 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 }) } @@ -22,6 +24,10 @@ class Comment < ApplicationRecord end private + def card_is_commentable + errors.add(:card, "does not allow comments") unless card.commentable? + end + def watch_card_by_creator card.watch_by creator end diff --git a/app/models/comment/searchable.rb b/app/models/comment/searchable.rb index 626d2be64..3194f9042 100644 --- a/app/models/comment/searchable.rb +++ b/app/models/comment/searchable.rb @@ -20,4 +20,8 @@ module Comment::Searchable def search_board_id card.board_id end + + def searchable? + card.published? + end end diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index 8b39504ef..2be34fe8d 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -1,6 +1,8 @@ module Searchable extend ActiveSupport::Concern + SEARCH_CONTENT_LIMIT = 32.kilobytes + included do after_create_commit :create_in_search_index after_update_commit :update_in_search_index @@ -13,11 +15,17 @@ module Searchable private def create_in_search_index - search_record_class.create!(search_record_attributes) + if searchable? + search_record_class.create!(search_record_attributes) + end end def update_in_search_index - search_record_class.upsert!(search_record_attributes) + if searchable? + search_record_class.upsert!(search_record_attributes) + else + remove_from_search_index + end end def remove_from_search_index @@ -32,11 +40,15 @@ module Searchable card_id: search_card_id, board_id: search_board_id, title: search_title, - content: search_content, + content: search_record_content, created_at: created_at } end + def search_record_content + search_content&.truncate_bytes(SEARCH_CONTENT_LIMIT, omission: "") + end + def search_record_class Search::Record.for(account_id) end @@ -47,4 +59,5 @@ module Searchable # - search_content: returns content string # - search_card_id: returns the card id (self.id for cards, card_id for comments) # - search_board_id: returns the board id + # - searchable?: returns whether this record should be indexed end diff --git a/app/models/reaction.rb b/app/models/reaction.rb index 76edf152e..be5fc5d7e 100644 --- a/app/models/reaction.rb +++ b/app/models/reaction.rb @@ -1,6 +1,6 @@ class Reaction < ApplicationRecord - belongs_to :account, default: -> { comment.account } - belongs_to :comment, touch: true + belongs_to :account, default: -> { reactable.account } + belongs_to :reactable, polymorphic: true, touch: true belongs_to :reacter, class_name: "User", default: -> { Current.user } scope :ordered, -> { order(:created_at) } @@ -11,6 +11,6 @@ class Reaction < ApplicationRecord private def register_card_activity - comment.card.touch_last_active_at + reactable.card.touch_last_active_at end end diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 74e274414..438560635 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -1,7 +1,7 @@ <% @page_title = "Account Settings" %> <% content_for :header do %> -

+

<%= @page_title %> <% unless Current.user.admin? %>
Only admins can change these settings
diff --git a/app/views/boards/columns/closeds/show.html.erb b/app/views/boards/columns/closeds/show.html.erb index 110db1c8c..ca6c48e83 100644 --- a/app/views/boards/columns/closeds/show.html.erb +++ b/app/views/boards/columns/closeds/show.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@board) %> -

+

<%= @page_title %>

<% end %> diff --git a/app/views/boards/columns/not_nows/show.html.erb b/app/views/boards/columns/not_nows/show.html.erb index f744635ae..e7de8a522 100644 --- a/app/views/boards/columns/not_nows/show.html.erb +++ b/app/views/boards/columns/not_nows/show.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@board) %> -

+

<%= @page_title %>

<% end %> diff --git a/app/views/boards/columns/show.html.erb b/app/views/boards/columns/show.html.erb index cd6c396e7..e514b75ae 100644 --- a/app/views/boards/columns/show.html.erb +++ b/app/views/boards/columns/show.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@column.board) %> -

+

<%= @page_title %>

<% end %> diff --git a/app/views/boards/columns/streams/show.html.erb b/app/views/boards/columns/streams/show.html.erb index 6ec6ac339..26dce763d 100644 --- a/app/views/boards/columns/streams/show.html.erb +++ b/app/views/boards/columns/streams/show.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@board) %> -

+

<%= @page_title %>

<% end %> diff --git a/app/views/boards/edit.html.erb b/app/views/boards/edit.html.erb index 36beee3c8..978208e92 100644 --- a/app/views/boards/edit.html.erb +++ b/app/views/boards/edit.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@board) %> -

+

<%= @page_title %>
<% unless Current.user.can_administer_board?(@board) %>
Only admins can change these settings
diff --git a/app/views/boards/edit/_delete.html.erb b/app/views/boards/edit/_delete.html.erb index ec346f590..9d9b7f441 100644 --- a/app/views/boards/edit/_delete.html.erb +++ b/app/views/boards/edit/_delete.html.erb @@ -3,7 +3,7 @@ <%= icon_tag "trash" %> Delete this board - +

Delete this board?

Are you sure you want to permanently delete this board and all the cards on it? This can't be undone.

diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb index 748028b8d..928237a84 100644 --- a/app/views/boards/new.html.erb +++ b/app/views/boards/new.html.erb @@ -1,11 +1,11 @@ <% @page_title = "Create a new board" %>
- <%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %> + <%= bridged_form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>

<%= @page_title %>

<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can‘t be blank" } %> - diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb index 36f3cd552..bcccf4512 100644 --- a/app/views/boards/show.html.erb +++ b/app/views/boards/show.html.erb @@ -5,15 +5,15 @@ <%= turbo_stream_from @board %> <% content_for :header do %> -
+
<%= link_to_webhooks(@board) if Current.user.admin? %>
-

+

<%= @board.name %>

-
+
<%= link_to_edit_board @board %>
<% end %> diff --git a/app/views/boards/show/_stream.html.erb b/app/views/boards/show/_stream.html.erb index 7773fb292..b43309100 100644 --- a/app/views/boards/show/_stream.html.erb +++ b/app/views/boards/show/_stream.html.erb @@ -7,7 +7,9 @@ <%= render "boards/show/expander", title: "Maybe?", count: board.cards.awaiting_triage.count, column_id: "maybe" %> <%= render "boards/show/menu/maximize", column_path: board_columns_stream_path(board) %> - <%= render "columns/show/add_card_button", board: board %> +
+ <%= render "columns/show/add_card_button", board: board %> +
<% if page.used? %> <%= with_automatic_pagination "maybe", @page do %> diff --git a/app/views/boards/show/menu/_column.html.erb b/app/views/boards/show/menu/_column.html.erb index fa3ab38ff..ebfdd3855 100644 --- a/app/views/boards/show/menu/_column.html.erb +++ b/app/views/boards/show/menu/_column.html.erb @@ -3,7 +3,7 @@ <%= icon_tag "menu-dots-horizontal", class: "translucent" %> Column options - +
<% end %> diff --git a/app/views/cards/comments/reactions/_reactions.html.erb b/app/views/cards/comments/reactions/_reactions.html.erb deleted file mode 100644 index ba3d491eb..000000000 --- a/app/views/cards/comments/reactions/_reactions.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -<%= turbo_frame_tag comment, :reacting do %> -
-
- <%= render partial: "cards/comments/reactions/reaction", collection: comment.reactions %> -
- - <%= turbo_frame_tag comment, :new_reaction do %> - <%= link_to new_card_comment_reaction_path(comment.card, comment), role: "button", - class: "reactions__trigger btn btn--circle", action: "soft-keyboard#open", - data: { turbo_frame: dom_id(comment, :new_reaction), action: "dialog#close" } do %> - <%= image_tag "boost-color.svg", aria: { hidden: true } %> - Add your own reaction - <% end %> - <% end %> -
-<% end %> diff --git a/app/views/cards/comments/reactions/create.turbo_stream.erb b/app/views/cards/comments/reactions/create.turbo_stream.erb deleted file mode 100644 index 7ac4b61df..000000000 --- a/app/views/cards/comments/reactions/create.turbo_stream.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= turbo_stream.replace([ @comment, :reacting ]) do %> - <%= render "cards/comments/reactions/reactions", comment: @comment.reload %> -<% end %> diff --git a/app/views/cards/comments/reactions/index.html.erb b/app/views/cards/comments/reactions/index.html.erb deleted file mode 100644 index 74863a9f1..000000000 --- a/app/views/cards/comments/reactions/index.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render "cards/comments/reactions/reactions", comment: @comment %> diff --git a/app/views/cards/comments/reactions/index.json.jbuilder b/app/views/cards/comments/reactions/index.json.jbuilder deleted file mode 100644 index 5dd744f12..000000000 --- a/app/views/cards/comments/reactions/index.json.jbuilder +++ /dev/null @@ -1 +0,0 @@ -json.array! @comment.reactions.ordered, partial: "cards/comments/reactions/reaction", as: :reaction diff --git a/app/views/cards/container/_closure.html.erb b/app/views/cards/container/_closure.html.erb index 8aa190c78..1afbaaaac 100644 --- a/app/views/cards/container/_closure.html.erb +++ b/app/views/cards/container/_closure.html.erb @@ -1,17 +1,13 @@
<% if card.closed? %> -
-
- - Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>. - <%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %> - Undo - <% end %> - -
+
+ Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>. + <%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %> + Undo + <% end %>
<% else %> -
+
<%= render "cards/container/closure_buttons", card: card %>
<% if card.entropic? && card.open? && !card.postponed? %> diff --git a/app/views/cards/container/_closure_buttons.html.erb b/app/views/cards/container/_closure_buttons.html.erb index 3a82ace0a..05e292315 100644 --- a/app/views/cards/container/_closure_buttons.html.erb +++ b/app/views/cards/container/_closure_buttons.html.erb @@ -1,12 +1,13 @@
- <%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless", data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", turbo_frame: dom_id(card, :edit) } do %> + <%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless", + data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Edit", bridge_icon_url: bridge_icon("pencil"), turbo_frame: dom_id(card, :edit) } do %> <%= icon_tag "pencil", class: "icon--mobile-only" %> Edit e <% end %> <%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless", - data: { controller: "hotkey", form_target: "submit", action: "keydown.d@document->hotkey#click" }, + 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" }, form: { data: { controller: "form" } } do %> <%= icon_tag "check", class: "icon--mobile-only" %> Mark as Done diff --git a/app/views/cards/container/_gild.html.erb b/app/views/cards/container/_gild.html.erb index 7647a3b4f..a40f0c0c2 100644 --- a/app/views/cards/container/_gild.html.erb +++ b/app/views/cards/container/_gild.html.erb @@ -1,10 +1,12 @@ <% if card.golden? %> - <%= button_to card_goldness_path(card), method: :delete, class: "btn btn--reversed", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click" } do %> + <%= button_to card_goldness_path(card), method: :delete, class: "btn btn--reversed", + data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Demote to normal" } do %> <%= icon_tag "golden-ticket" %> Demote to normal (shift+g) <% end %> <% else %> - <%= button_to card_goldness_path(card), class: "btn", data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click" } do %> + <%= button_to card_goldness_path(card), class: "btn", + data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Promote to Golden Ticket" } do %> <%= icon_tag "golden-ticket" %> Promote to Golden Ticket (shift+g) <% end %> diff --git a/app/views/cards/container/_image.html.erb b/app/views/cards/container/_image.html.erb index 6f0fceea0..9edda2249 100644 --- a/app/views/cards/container/_image.html.erb +++ b/app/views/cards/container/_image.html.erb @@ -1,11 +1,12 @@ <% if card.image.attached? %> - <%= button_to card_image_path(card), method: :delete, class: "btn", data: { controller: "tooltip" } do %> + <%= button_to card_image_path(card), method: :delete, class: "btn", + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Remove background image" } do %> <%= icon_tag "picture-remove" %> Remove background image <% end %> <% elsif !card.closed? %> <%= form_with model: card, data: { controller: "form" } do |form| %> -
<%= render "cards/display/preview/meta", card: card, preview: true %> - <%= render "cards/display/preview/comments", card: card %> +
+ <%= render "cards/display/preview/boosts", card: card %> + <%= render "cards/display/preview/comments", card: card %> +
<%= render "cards/display/common/background", card: card %>
diff --git a/app/views/cards/display/_public_preview.html.erb b/app/views/cards/display/_public_preview.html.erb index 31b511a44..7aa53fed7 100644 --- a/app/views/cards/display/_public_preview.html.erb +++ b/app/views/cards/display/_public_preview.html.erb @@ -22,6 +22,7 @@
<%= render "cards/display/public_preview/columns", card: card if card.triaged? %> + <%= render "cards/display/common/stamp", card: card %>
diff --git a/app/views/cards/display/common/_background.html.erb b/app/views/cards/display/common/_background.html.erb index feb0fbd1c..c0e18f18b 100644 --- a/app/views/cards/display/common/_background.html.erb +++ b/app/views/cards/display/common/_background.html.erb @@ -5,19 +5,3 @@ <%= yield %> <% end %> - -<% if card.postponed? %> -
"> - Not Now - <%= card.postponed_at.strftime("%b %d, %Y") %> - -
-<% end %> - -<% if card.closed? %> -
- Done - <%= card.closed_at.strftime("%b %d, %Y") %> - -
-<% end %> diff --git a/app/views/cards/display/common/_board.html.erb b/app/views/cards/display/common/_board.html.erb index 3e91a0d78..e5595ab39 100644 --- a/app/views/cards/display/common/_board.html.erb +++ b/app/views/cards/display/common/_board.html.erb @@ -1,10 +1,8 @@ -