Merge branch 'main' into pins-api

* main: (127 commits)
  Align board name start so it doesn't get too big
  Clean up card/events header layout
  Sync email to Stripe when user changes email address (#2432)
  Fix IDOR in webhook activation endpoint (#2431)
  Add card reactions to API docs and reactions_url to card JSON (#2427)
  Remove unnecessary claude plan
  Allow boosts on cards (#2411)
  Revert "Fix notification click URL by using correct data property"
  Add migration to remove draft cards from search index
  Guard search indexing with searchable? check
  Forbid comments on draft cards
  prefactor: update search to use published cards
  Fix notification click URL by using correct data property
  Wait for service worker to be active before subscribing
  Fix stuck state when permission granted but no subscription
  Extract Card::Commentable
  Include arm64 build in Docker workflow
  Remove unnecessary `await` in push handler
  Correctly initialise WebPush connection (#2417)
  Update models, views, and fixtures for polymorphic reactions
  ...
This commit is contained in:
Denis Švara
2026-01-27 15:37:47 +01:00
188 changed files with 2248 additions and 467 deletions
+1
View File
@@ -0,0 +1 @@
@../AGENTS.md
+7 -2
View File
@@ -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"
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+4 -4
View File
@@ -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)
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 17"><path d="m11.5 10.3-.6-.2c-.2-.5-.3-1.1-.1-1.6 1.1-1.2 1.6-2.7 1.5-4.3 0-2.4-1.6-4.2-3.8-4.2S4.7 1.8 4.7 4.2c-.1 1.5.4 3.1 1.5 4.3.2.5.2 1.1-.1 1.6l-.6.2c-2.4.9-4.2 1.5-4.8 2.6-.4 1.2-.7 2.4-.7 3.6 0 .3.2.5.5.5h16c.3 0 .5-.2.5-.5 0-1.2-.3-2.4-.7-3.5-.6-1.1-2.4-1.8-4.8-2.7"/></svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m15.5294 14.1176-.7059-.2352c-.2353-.5883-.3529-1.2942-.1176-1.8824 1.2941-1.4118 1.8823-3.17647 1.7647-5.05882 0-2.82353-1.8824-4.94118-4.4706-4.94118-2.58824 0-4.47059 2.11765-4.47059 4.94118-.11765 1.7647.47059 3.64702 1.76471 5.05882.23529.5882.23529 1.2941-.11765 1.8824l-.70588.2352c-2.82353 1.0589-4.94118 1.7648-5.64706 3.0589-.47059 1.4117-.82353 2.8235-.82353 4.2353 0 .3529.23529.5882.58824.5882h18.82356c.3529 0 .5882-.2353.5882-.5882 0-1.4118-.3529-2.8236-.8235-4.1177-.7059-1.2941-2.8236-2.1176-5.6471-3.1765z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 614 B

+44 -16
View File
@@ -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
+103 -37
View File
@@ -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));
}
}
}
}
+60 -11
View File
@@ -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;
+5
View File
@@ -80,6 +80,11 @@
display: none !important;
}
}
.reactions {
margin-block-start: var(--block-space-half);
margin-inline: calc(var(--column-gap) / -1);
}
}
.comment__author {
+10
View File
@@ -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);
}
}
+10 -11
View File
@@ -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 {
+159 -5
View File
@@ -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 {
+40
View File
@@ -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;
}
}
+14 -1
View File
@@ -21,11 +21,16 @@
display: var(--popup-display);
}
/* The .pop-up--align-<drection> classes are used for initial alignment.
* The orient JS helper layers on the .orient-<direction> 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;
}
+2 -2
View File
@@ -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;
+1 -6
View File
@@ -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);
}
}
}
@@ -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
-33
View File
@@ -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;
}
}
}
+1
View File
@@ -440,6 +440,7 @@
.card__meta-text:not(.card__meta-text--updated),
.card__stages,
.card__steps,
.card__boosts,
.card__comments,
.card__closed {
display: none;
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
module BridgeHelper
def bridge_icon(name)
asset_url("#{name}.svg")
end
end
+1
View File
@@ -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
+4
View File
@@ -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
+19
View File
@@ -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
+10
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
@@ -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)
}
}
@@ -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" ]
})
}
}
@@ -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`)
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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() {
@@ -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" })
@@ -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
}
}
@@ -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()
}
@@ -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)
@@ -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()
@@ -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
}
+1
View File
@@ -1 +1,2 @@
import "initializers/current"
import "initializers/bridge/bridge_element"
@@ -0,0 +1,7 @@
class Card::CleanInaccessibleDataJob < ApplicationJob
discard_on ActiveJob::DeserializationError
def perform(card)
card.clean_inaccessible_data
end
end
+5 -63
View File
@@ -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
+26
View File
@@ -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
+61
View File
@@ -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
+4
View File
@@ -25,4 +25,8 @@ module Card::Searchable
def search_board_id
board_id
end
def searchable?
published?
end
end
+1
View File
@@ -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
+7 -1
View File
@@ -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
+4
View File
@@ -20,4 +20,8 @@ module Comment::Searchable
def search_board_id
card.board_id
end
def searchable?
card.published?
end
end
+16 -3
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
<% @page_title = "Account Settings" %>
<% content_for :header do %>
<h1 class="header__title">
<h1 class="header__title" data-bridge--title-target="header">
<%= @page_title %>
<% unless Current.user.admin? %>
<div class="txt-normal font-weight-normal">Only admins can change these settings</div>
@@ -5,7 +5,7 @@
<%= link_back_to_board(@board) %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
@@ -5,7 +5,7 @@
<%= link_back_to_board(@board) %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<%= link_back_to_board(@column.board) %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
@@ -5,7 +5,7 @@
<%= link_back_to_board(@board) %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @page_title %></span>
</h1>
<% end %>
+1 -1
View File
@@ -5,7 +5,7 @@
<%= link_back_to_board(@board) %>
</div>
<h1 class="header__title">
<h1 class="header__title" data-bridge--title-target="header">
<div><%= @page_title %></div>
<% unless Current.user.can_administer_board?(@board) %>
<div class="txt-normal font-weight-normal">Only admins can change these settings</div>
+1 -1
View File
@@ -3,7 +3,7 @@
<%= icon_tag "trash" %>
<span>Delete this board</span>
</button>
<dialog class="dialog panel fill-white shadow gap flex-column" style="max-width: 40ch" data-dialog-target="dialog">
<dialog class="dialog panel fill-white shadow gap flex-column" style="--panel-size: 40ch" data-dialog-target="dialog">
<h3 class="txt-large txt-bold">Delete this board?</h3>
<p class="txt-medium margin-block-half">Are you sure you want to permanently delete this board and all the cards on it? This can't be undone.</p>
<div class="flex gap-half justify-center margin-block-start">
+2 -2
View File
@@ -1,11 +1,11 @@
<% @page_title = "Create a new board" %>
<div class="panel panel--centered">
<%= 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| %>
<h1 class="txt-x-large margin-none font-weight-black"><%= @page_title %></h1>
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names cant be blank" } %>
<button type="submit" class="btn btn--link center">
<button type="submit" class="btn btn--link center" data-bridge--form-target="submit">
<span>Create board</span>
<%= icon_tag "arrow-right" %>
</button>
+3 -3
View File
@@ -5,15 +5,15 @@
<%= turbo_stream_from @board %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<div class="header__actions header__actions--start hide-on-native">
<%= link_to_webhooks(@board) if Current.user.admin? %>
</div>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis"><%= @board.name %></span>
</h1>
<div class="header__actions header__actions--end">
<div class="header__actions header__actions--end hide-on-native">
<%= link_to_edit_board @board %>
</div>
<% end %>
+3 -1
View File
@@ -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) %>
</header>
<%= render "columns/show/add_card_button", board: board %>
<div data-bridge-disabled="true">
<%= render "columns/show/add_card_button", board: board %>
</div>
<div class="cards__list hide-scrollbar" data-drag-drop-item-container>
<% if page.used? %>
<%= with_automatic_pagination "maybe", @page do %>
+1 -1
View File
@@ -3,7 +3,7 @@
<%= icon_tag "menu-dots-horizontal", class: "translucent" %>
<span class="for-screen-reader">Column options</span>
</button>
<dialog class="popup panel flex-column gap fill-white shadow txt-small margin-block-double" data-dialog-target="dialog">
<dialog class="popup popup--align-right panel flex-column gap fill-white shadow txt-small margin-block-double" data-dialog-target="dialog">
<ul class="popup__list">
<li class="popup__item">
<button class="popup__btn btn" data-action="click->clicker#click">
+1
View File
@@ -20,4 +20,5 @@ json.cache! card do
json.has_more_assignees card.assignees.size > 5
json.comments_url card_comments_url(card)
json.reactions_url card_reactions_url(card)
end
+5 -2
View File
@@ -1,4 +1,4 @@
<section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;">
<section id="<%= dom_id(card, :card_container) %>" class="card-perma" style="--card-color: <%= card.color %>;" data-controller="dialog-manager bridge--form">
<% cache card do %>
<div class="card-perma__actions card-perma__actions--left">
<%= render "cards/container/gild", card: card if card.published? && !card.closed? %>
@@ -21,11 +21,14 @@
<% if card.published? %>
<%= render "cards/triage/columns", card: card %>
<% end %>
<%= render "cards/display/common/stamp", card: card %>
</div>
<footer class="card__footer full-width flex align-start gap">
<footer class="card__footer">
<%= render "cards/display/perma/meta", card: card %>
<%= render "cards/display/perma/background", card: card %>
<%= render "reactions/reactions", reactable: card %>
</footer>
<% end %>
+1 -1
View File
@@ -3,7 +3,7 @@
<%= icon_tag "trash" %>
<span>Delete this card</span>
</button>
<dialog class="dialog panel fill-white shadow gap flex-column" style="max-width: 40ch" data-dialog-target="dialog">
<dialog class="dialog panel fill-white shadow gap flex-column" style="--panel-size: 40ch" data-dialog-target="dialog">
<h3 class="txt-large txt-bold">Delete this card?</h3>
<p class="txt-medium margin-block-half">Are you sure you want to permanently delete this card?</p>
<div class="flex gap-half justify-center margin-block-start">
+2 -2
View File
@@ -5,10 +5,10 @@
dialog_target: "dialog",
navigable_list_focus_on_selection_value: false,
navigable_list_actionable_items_value: true } do %>
<strong class="popup__title pad-inline-half">Move this card to…</strong>
<%= filter_title "Move this card to…" %>
<%= text_field_tag :search, nil, placeholder: "Filter…", class: "input input--transparent txt-small margin-block-half font-weight-normal", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", dialog_target: "focusMouse", action: "input->filter#filter" } %>
<ul class="popup__list margin-block-start-half margin-none-block-end" data-filter-target="list">
<% @boards.each do |board| %>
+1 -1
View File
@@ -36,7 +36,7 @@
<%= comment.body %>
</div>
<%= render "cards/comments/reactions/reactions", comment: comment %>
<%= render "reactions/reactions", reactable: comment %>
</div>
</div>
<% end %>
@@ -1,16 +0,0 @@
<%= turbo_frame_tag comment, :reacting do %>
<div class="reactions">
<div id="<%= dom_id(comment, :reactions) %>" class="reactions__list">
<%= render partial: "cards/comments/reactions/reaction", collection: comment.reactions %>
</div>
<%= 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 } %>
<span class="for-screen-reader">Add your own reaction</span>
<% end %>
<% end %>
</div>
<% end %>
@@ -1,3 +0,0 @@
<%= turbo_stream.replace([ @comment, :reacting ]) do %>
<%= render "cards/comments/reactions/reactions", comment: @comment.reload %>
<% end %>
@@ -1 +0,0 @@
<%= render "cards/comments/reactions/reactions", comment: @comment %>
@@ -1 +0,0 @@
json.array! @comment.reactions.ordered, partial: "cards/comments/reactions/reaction", as: :reaction
+6 -10
View File
@@ -1,17 +1,13 @@
<div class="display-contents" id="<%= dom_id(card, :card_closure_toggle) %>">
<% if card.closed? %>
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<div class="flex gap-half margin-block-start-double margin-block-end">
<span class="card-perma__closure-message">
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 %>
<span class="pad-inline-end">Undo</span>
<% end %>
</span>
</div>
<div class="card-perma__closure-message flex gap-half justify-center">
<span>Completed by <%= card.closed_by.name %> on <%= local_datetime_tag(card.closed_at, style: :shortdate) %>.</span>
<%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %>
<span class="pad-inline-end">Undo</span>
<% end %>
</div>
<% else %>
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<div class="card-perma__notch card-perma__notch--bottom">
<%= render "cards/container/closure_buttons", card: card %>
</div>
<% if card.entropic? && card.open? && !card.postponed? %>
@@ -1,12 +1,13 @@
<div class="flex gap-half">
<%= 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" %>
<span>Edit</span>
<kbd class="txt-x-small hide-on-touch">e</kbd>
<% 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" %>
<span class="overflow-ellipsis">Mark as Done</span>
+4 -2
View File
@@ -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" %>
<span class="for-screen-reader">Demote to normal (shift+g)</span>
<% 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" %>
<span class="for-screen-reader">Promote to Golden Ticket (shift+g)</span>
<% end %>
+3 -2
View File
@@ -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" %>
<span class="for-screen-reader">Remove background image</span>
<% end %>
<% elsif !card.closed? %>
<%= form_with model: card, data: { controller: "form" } do |form| %>
<label class="card-perma__image-btn btn input--file" data-controller="tooltip">
<label class="card-perma__image-btn btn input--file" data-controller="tooltip" data-bridge--overflow-menu-target="item" data-bridge-title="Add background image">
<%= icon_tag "picture-add" %>
<span class="for-screen-reader">Add background image</span>
@@ -1,7 +1,7 @@
<div class="card-perma__notch card-perma__notch--bottom flex-column">
<%= button_tag type: :submit, form: dom_id(card, :edit_form), class: "btn borderless",
title: "Save changes (#{ hotkey_label(["ctrl", "enter"]) })",
data: { controller: "hotkey", action: "keydown.ctrl+enter@document->hotkey#click keydown.meta+enter@document->hotkey#click" } do %>
data: { controller: "hotkey", bridge__form_target: "submit", action: "keydown.ctrl+enter@document->hotkey#click keydown.meta+enter@document->hotkey#click" } do %>
<span>Save changes</span>
<% end %>
</div>
@@ -2,8 +2,8 @@
<div class="card-perma__notch-new-card-buttons">
<%= 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 %>
form: { data: { controller: "form bridge--form" } },
data: { form_target: "submit", bridge__form_target: "submit", controller: "clicker", action: "keydown.ctrl+enter@document->clicker#click keydown.meta+enter@document->clicker#click" } do %>
<span>Create card</span>
<% end %>
+6 -4
View File
@@ -11,9 +11,7 @@
<% card_data[:card_not_now_url] = card_not_now_path(card) %>
<% card_data[:card_closure_url] = card_closure_path(card) %>
<% card_data[:action] = "mouseenter->navigable-list#hoverSelect" %>
<% if Current.user %>
<% card_data[:card_assign_to_me_url] = card_assignments_path(card, params: { assignee_id: Current.user.id }) %>
<% end %>
<% card_data[:card_assign_to_self_url] = card_self_assignment_path(card) %>
<% end %>
<%= card_article_tag card, class: "card", draggable: draggable, data: card_data, tabindex: 0 do %>
@@ -43,12 +41,16 @@
</div>
<%= render "cards/display/preview/columns", card: card %>
<%= render "cards/display/common/stamp", card: card %>
</div>
</div>
<footer class="card__footer flex gap-half">
<%= render "cards/display/preview/meta", card: card, preview: true %>
<%= render "cards/display/preview/comments", card: card %>
<div class="card__counts">
<%= render "cards/display/preview/boosts", card: card %>
<%= render "cards/display/preview/comments", card: card %>
</div>
<%= render "cards/display/common/background", card: card %>
</footer>
@@ -22,6 +22,7 @@
</div>
<%= render "cards/display/public_preview/columns", card: card if card.triaged? %>
<%= render "cards/display/common/stamp", card: card %>
</div>
</div>
@@ -5,19 +5,3 @@
<%= yield %>
<% end %>
<% if card.postponed? %>
<div class="card__closed <%= "card__closed--system" if card.postponed_by&.system? %>">
<span class="card__closed-title" data-text="Not Now">Not Now</span>
<strong class="card__closed-date"><%= card.postponed_at.strftime("%b %d, %Y") %></strong>
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.postponed_by&.familiar_name %></span></span>
</div>
<% end %>
<% if card.closed? %>
<div class="card__closed">
<span class="card__closed-title" data-text="Done">Done</span>
<strong class="card__closed-date"><%= card.closed_at.strftime("%b %d, %Y") %></strong>
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.closed_by.familiar_name %></span></span>
</div>
<% end %>
@@ -1,10 +1,8 @@
<label class="card__board">
<span class="card__id">
<span class="for-screen-reader">Card number</span>
<%= card.number %>
</span>
<span class="card__board-name">
<span class="overflow-ellipsis"><%= card.board.name %></span>
</span>
<div class="card__id">
<span class="for-screen-reader">Card number</span>
<%= card.number %>
</div>
<div class="card__board-name">
<span class="overflow-ellipsis"><%= card.board.name %></span>
<%= yield %>
</label>
</div>
@@ -0,0 +1,15 @@
<% if card.postponed? %>
<div class="card__closed <%= "card__closed--system" if card.postponed_by&.system? %>">
<span class="card__closed-title" data-text="Not Now">Not Now</span>
<strong class="card__closed-date"><%= card.postponed_at.strftime("%b %d, %Y") %></strong>
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.postponed_by&.familiar_name %></span></span>
</div>
<% end %>
<% if card.closed? %>
<div class="card__closed">
<span class="card__closed-title" data-text="Done">Done</span>
<strong class="card__closed-date"><%= card.closed_at.strftime("%b %d, %Y") %></strong>
<span class="card__closed-by-line">by <span class="card__closed-by"><%= card.closed_by.familiar_name %></span></span>
</div>
<% end %>
@@ -1,5 +1,5 @@
<%= render "cards/display/common/background", card: card do %>
<%= link_to card.image.presence, class: "btn fill-transparent flex-item-justify-end", data: { controller: "tooltip", lightbox_target: "image" } do %>
<%= link_to card.image.presence, class: "card__zoom-bg-btn btn", data: { controller: "tooltip", lightbox_target: "image" } do %>
<%= icon_tag "picture-zoom" %>
<span class="for-screen-reader">Zoom background image</span>
<% end %>
+9 -14
View File
@@ -1,14 +1,9 @@
<%= render "cards/display/common/board", card: card do %>
<div class="align-self-center position-relative txt-small"
data-controller="dialog"
data-action="keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames" <%= "hidden" if card.closed? %>>
<button class="board-picker__button btn btn--reversed card__hide-on-index fill-transparent" data-action="click->dialog#open:stop">
<%= icon_tag "caret-down" %>
<span class="for-screen-reader">Choose a board for this card</span>
</button>
<dialog class="popup panel flex-column align-start gap-half fill-white shadow margin-block-double" data-dialog-target="dialog" data-action="turbo:before-morph-attribute->dialog#preventCloseOnMorphing turbo:submit-end->dialog#close">
<%= turbo_frame_tag "board_picker", src: edit_card_board_path(card), target: "_top", loading: :lazy, refresh: "morph" %>
</dialog>
</div>
<% end %>
<div class="card__board" data-controller="dialog" data-action="keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames">
<%= render "cards/display/common/board", card: card do %>
<%= icon_tag "caret-down", class: "txt-xx-small", hidden: card.closed? %>
<% end %>
<button class="card__board-picker-button btn btn--plain" data-action="click->dialog#open:stop" aria-label="Choose a board for this card" <%= "hidden" if card.closed? %>></button>
<dialog class="popup popup--align-right panel flex-column align-start gap-half fill-white shadow" data-dialog-target="dialog" data-action="turbo:before-morph-attribute->dialog#preventCloseOnMorphing turbo:submit-end->dialog#close">
<%= turbo_frame_tag "board_picker", src: edit_card_board_path(card), target: "_top", loading: :lazy, refresh: "morph" %>
</dialog>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
<div id="<%= dom_id(card, :tags) %>" class="card__tags">
<div class="position-relative" data-controller="dialog"
<div data-controller="dialog"
data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames" <%= "hidden" if card.closed? %>>
<button class="tag-picker__button btn card__hide-on-index" style="--btn-background: var(--card-bg-color);"
<button class="card__tag-picker-button btn card__hide-on-index" style="--btn-background: var(--card-bg-color);"
data-controller="tooltip hotkey" data-action="click->dialog#open:stop keydown.t@document->hotkey#click">
<%= icon_tag "tag" %>
<span class="for-screen-reader">Add a tag</span>
@@ -1 +1,3 @@
<%= render "cards/display/common/board", card: card %>
<div class="card__board">
<%= render "cards/display/common/board", card: card %>
</div>
@@ -0,0 +1,7 @@
<% boosts = card.reactions %>
<% if boosts.any? %>
<div class="card__boosts">
<%= image_tag "boost-color.svg", aria: { hidden: true } %>
<span><%= boosts.size %></span>
</div>
<% end %>
@@ -1,7 +1,7 @@
<% comments = card.comments.by_user %>
<% if comments.any? %>
<div class="card__comments align-center gap-half flex-item-justify-end flex-item-no-shrink">
<div class="card__comments">
<%= icon_tag "comment" %>
<strong><%= comments.count %></strong>
<span><%= comments.count %></span>
</div>
<% end %>
+1 -1
View File
@@ -18,7 +18,7 @@
</div>
</div>
<footer class="card__footer full-width flex align-start gap">
<footer class="card__footer">
<%= render "cards/display/perma/meta", card: card %>
<%= render "cards/display/perma/background", card: card %>
</footer>
+2 -1
View File
@@ -22,6 +22,7 @@
<%= general_prompts(@card.board) %>
<% end %>
<%= link_to "Close editor and discard changes", @card, data: { form_target: "cancel" }, hidden: true %>
<%= link_to "Close editor and discard changes", @card,
data: { form_target: "cancel", bridge__form_target: "cancel", bridge_title: "Cancel" }, hidden: true %>
<% end %>
<% end %>
+1 -1
View File
@@ -4,7 +4,7 @@
<%= render "cards/broadcasts", filter: @filter %>
<% content_for :header do %>
<h1 class="header__title divider divider--fade full-width">
<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
<span class="overflow-ellipsis txt-capitalize-first-letter"><%= @user_filtering.selected_boards_label %></span>
</h1>
+4 -2
View File
@@ -1,10 +1,12 @@
<div id="<%= dom_id(card, :pin_button) %>">
<% if card.pinned_by? Current.user %>
<%= button_to card_pin_path(card), method: :delete, class: "btn btn--reversed", data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click" } do %>
<%= button_to card_pin_path(card), method: :delete, class: "btn btn--reversed",
data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Un-pin this card" } do %>
<%= icon_tag "pinned" %> <span class="for-screen-reader">Un-pin this card (shift+p)</span>
<% end %>
<% else %>
<%= button_to card_pin_path(card), class: "btn", data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click" } do %>
<%= button_to card_pin_path(card), class: "btn",
data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Pin this card" } do %>
<%= icon_tag "unpinned" %> <span class="for-screen-reader">Pin this card (shift+p)</span>
<% end %>
<% end %>
@@ -1,3 +1,7 @@
<%= turbo_stream.replace @step do %>
<%= render "cards/steps/step", step: @step %>
<% end %>
<%= turbo_stream.replace dom_id(@card, "bubble") do %>
<%= render "cards/display/preview/bubble", card: @card %>
<% end %>

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