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:
@@ -0,0 +1 @@
|
||||
@../AGENTS.md
|
||||
@@ -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"
|
||||
|
||||
|
||||
+4
-4
@@ -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
@@ -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 @@
|
||||
<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 |
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -80,6 +80,11 @@
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.reactions {
|
||||
margin-block-start: var(--block-space-half);
|
||||
margin-inline: calc(var(--column-gap) / -1);
|
||||
}
|
||||
}
|
||||
|
||||
.comment__author {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module BridgeHelper
|
||||
def bridge_icon(name)
|
||||
asset_url("#{name}.svg")
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,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 +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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -25,4 +25,8 @@ module Card::Searchable
|
||||
def search_board_id
|
||||
board_id
|
||||
end
|
||||
|
||||
def searchable?
|
||||
published?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,4 +20,8 @@ module Comment::Searchable
|
||||
def search_board_id
|
||||
card.board_id
|
||||
end
|
||||
|
||||
def searchable?
|
||||
card.published?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 can‘t 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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user