Merge branch 'main' into smooth-theme-change
This commit is contained in:
@@ -9,3 +9,11 @@ paths = [
|
||||
'''docs/''',
|
||||
'''test/''',
|
||||
]
|
||||
|
||||
[[rules]]
|
||||
id = "basecamp-integration-url"
|
||||
description = "Basecamp Integration URL"
|
||||
regex = '''https://[^\s]*?([0-9a-fA-F]{16,})'''
|
||||
[rules.allowlist]
|
||||
regexTarget = "match"
|
||||
regexes = ['''github\.com''']
|
||||
|
||||
@@ -11,3 +11,5 @@ AllCops:
|
||||
Exclude:
|
||||
- 'db/migrate/**/*'
|
||||
- 'db/schema*.rb'
|
||||
- 'saas/db/migrate/**/*'
|
||||
- 'saas/db/saas_schema.rb'
|
||||
|
||||
+15
-9
@@ -122,8 +122,8 @@ GEM
|
||||
ast (2.4.3)
|
||||
autotuner (1.1.0)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1187.0)
|
||||
aws-sdk-core (3.239.1)
|
||||
aws-partitions (1.1197.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -134,7 +134,7 @@ GEM
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.205.0)
|
||||
aws-sdk-s3 (1.208.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -144,7 +144,7 @@ GEM
|
||||
bcrypt (3.1.20)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (3.3.1)
|
||||
bigdecimal (4.0.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.19.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -190,6 +190,7 @@ GEM
|
||||
ffi (1.17.2-arm-linux-gnu)
|
||||
ffi (1.17.2-arm-linux-musl)
|
||||
ffi (1.17.2-arm64-darwin)
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
fugit (1.12.1)
|
||||
@@ -267,7 +268,7 @@ GEM
|
||||
railties (>= 7.1)
|
||||
stimulus-rails
|
||||
turbo-rails
|
||||
mittens (0.3.0)
|
||||
mittens (0.3.1)
|
||||
mocha (2.8.2)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.8.0)
|
||||
@@ -298,6 +299,8 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-linux-musl)
|
||||
@@ -422,6 +425,7 @@ GEM
|
||||
sqlite3 (2.8.0-arm-linux-gnu)
|
||||
sqlite3 (2.8.0-arm-linux-musl)
|
||||
sqlite3 (2.8.0-arm64-darwin)
|
||||
sqlite3 (2.8.0-x86_64-darwin)
|
||||
sqlite3 (2.8.0-x86_64-linux-gnu)
|
||||
sqlite3 (2.8.0-x86_64-linux-musl)
|
||||
sshkit (1.24.0)
|
||||
@@ -435,10 +439,11 @@ GEM
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.9)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
thruster (0.1.16-arm64-darwin)
|
||||
thruster (0.1.16-x86_64-linux)
|
||||
thruster (0.1.17)
|
||||
thruster (0.1.17-aarch64-linux)
|
||||
thruster (0.1.17-arm64-darwin)
|
||||
thruster (0.1.17-x86_64-darwin)
|
||||
thruster (0.1.17-x86_64-linux)
|
||||
timeout (0.4.4)
|
||||
trilogy (2.9.0)
|
||||
tsort (0.2.0)
|
||||
@@ -481,6 +486,7 @@ PLATFORMS
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
arm64-darwin
|
||||
x86_64-darwin-25
|
||||
x86_64-linux
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
+2
-2
@@ -3,10 +3,10 @@ eval_gemfile "Gemfile"
|
||||
|
||||
git_source(:bc) { |repo| "https://github.com/basecamp/#{repo}" }
|
||||
|
||||
# SaaS-only functionality
|
||||
gem "activeresource", require: "active_resource"
|
||||
gem "stripe", "~> 18.0"
|
||||
gem "queenbee", bc: "queenbee-plugin"
|
||||
gem "fizzy-saas", bc: "fizzy-saas"
|
||||
gem "fizzy-saas", path: "saas"
|
||||
gem "console1984", bc: "console1984"
|
||||
gem "audits1984", bc: "audits1984"
|
||||
|
||||
|
||||
+32
-31
@@ -19,28 +19,6 @@ GIT
|
||||
rails (>= 7.0)
|
||||
rainbow
|
||||
|
||||
GIT
|
||||
remote: https://github.com/basecamp/fizzy-saas
|
||||
revision: f0bc5d811ff80b45dc44b88a13a0a17a3e823143
|
||||
specs:
|
||||
fizzy-saas (0.1.0)
|
||||
audits1984
|
||||
console1984
|
||||
prometheus-client-mmap (~> 1.4.0)
|
||||
queenbee
|
||||
rails (>= 8.1.0.beta1)
|
||||
rails_structured_logging
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
yabeda
|
||||
yabeda-actioncable
|
||||
yabeda-activejob
|
||||
yabeda-gc
|
||||
yabeda-http_requests
|
||||
yabeda-prometheus-mmap
|
||||
yabeda-puma-plugin
|
||||
yabeda-rails (>= 0.10)
|
||||
|
||||
GIT
|
||||
remote: https://github.com/basecamp/lexxy
|
||||
revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8
|
||||
@@ -181,6 +159,27 @@ GIT
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
|
||||
PATH
|
||||
remote: saas
|
||||
specs:
|
||||
fizzy-saas (0.1.0)
|
||||
audits1984
|
||||
console1984
|
||||
prometheus-client-mmap (~> 1.4.0)
|
||||
queenbee
|
||||
rails (>= 8.1.0.beta1)
|
||||
rails_structured_logging
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
yabeda
|
||||
yabeda-actioncable
|
||||
yabeda-activejob
|
||||
yabeda-gc
|
||||
yabeda-http_requests
|
||||
yabeda-prometheus-mmap
|
||||
yabeda-puma-plugin
|
||||
yabeda-rails (>= 0.10)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
@@ -201,8 +200,8 @@ GEM
|
||||
ast (2.4.3)
|
||||
autotuner (1.1.0)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1187.0)
|
||||
aws-sdk-core (3.239.1)
|
||||
aws-partitions (1.1197.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
@@ -213,7 +212,7 @@ GEM
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.205.0)
|
||||
aws-sdk-s3 (1.208.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
@@ -223,7 +222,7 @@ GEM
|
||||
bcrypt (3.1.20)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (3.3.1)
|
||||
bigdecimal (4.0.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.19.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -347,7 +346,7 @@ GEM
|
||||
railties (>= 7.1)
|
||||
stimulus-rails
|
||||
turbo-rails
|
||||
mittens (0.3.0)
|
||||
mittens (0.3.1)
|
||||
mocha (2.8.2)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.8.0)
|
||||
@@ -558,11 +557,12 @@ GEM
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.9)
|
||||
stripe (18.0.1)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.16)
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
thruster (0.1.16-arm64-darwin)
|
||||
thruster (0.1.16-x86_64-linux)
|
||||
thruster (0.1.17)
|
||||
thruster (0.1.17-aarch64-linux)
|
||||
thruster (0.1.17-arm64-darwin)
|
||||
thruster (0.1.17-x86_64-linux)
|
||||
timeout (0.4.4)
|
||||
trilogy (2.9.0)
|
||||
tsort (0.2.0)
|
||||
@@ -684,6 +684,7 @@ DEPENDENCIES
|
||||
solid_queue (~> 1.2)
|
||||
sqlite3 (>= 2.0)
|
||||
stimulus-rails
|
||||
stripe (~> 18.0)
|
||||
thruster
|
||||
trilogy (~> 2.9)
|
||||
turbo-rails
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3" /></svg>
|
||||
|
After Width: | Height: | Size: 308 B |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.8 KiB |
@@ -1,4 +1,4 @@
|
||||
@layer reset, base, components, modules, utilities, mobile_app, android, ios;
|
||||
@layer reset, base, components, modules, utilities, native, platform;
|
||||
|
||||
:root {
|
||||
/* Spacing */
|
||||
@@ -79,6 +79,7 @@
|
||||
--z-tooltip: 50;
|
||||
--z-bar: 60;
|
||||
--z-tray: 61;
|
||||
--z-welcome: 62;
|
||||
|
||||
/* OKLCH colors: Fixed */
|
||||
--lch-black: 0% 0 0;
|
||||
@@ -0,0 +1,7 @@
|
||||
@layer platform {
|
||||
[data-platform~=android] {
|
||||
.hide-on-android {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@layer android {
|
||||
.hide-in-android-app {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,15 @@
|
||||
33% { background-color: var(--color-border-darker); scale: 1; }
|
||||
}
|
||||
|
||||
@keyframes wiggle {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(3deg); }
|
||||
40% { transform: rotate(-3deg); }
|
||||
60% { transform: rotate(3deg); }
|
||||
80% { transform: rotate(-3deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@keyframes wobble {
|
||||
0% { transform: rotate(calc(var(--bubble-rotate) + 30deg)); }
|
||||
15% { border-radius: 66% 34% 72% 28% / 39% 63% 37% 61%; }
|
||||
@@ -461,7 +461,23 @@
|
||||
font-size: clamp(0.6rem, 0.85cqi, 100px);
|
||||
}
|
||||
|
||||
.card__comments {
|
||||
--column-gap: 0.8ch;
|
||||
|
||||
display: flex;
|
||||
margin-block-end: calc(var(--block-space) * -1.7);
|
||||
margin-inline-end: calc(var(--card-padding-inline) * -0.5);
|
||||
|
||||
.icon {
|
||||
--icon-size: 1.6em;
|
||||
|
||||
color: var(--card-color);
|
||||
}
|
||||
}
|
||||
|
||||
.card__steps {
|
||||
--column-gap: 0.8ch;
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
border-radius: 99rem;
|
||||
}
|
||||
|
||||
.btn:not(.popup__btn, .btn--plain, .btn--reversed) {
|
||||
.btn:not(.popup__btn, .btn--plain, .btn--reversed, .settings-subscription__button) {
|
||||
--btn-background: var(--card-color);
|
||||
--btn-color: var(--color-ink-inverted);
|
||||
}
|
||||
@@ -299,6 +299,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card-perma__notch-new-card-buttons {
|
||||
display: flex;
|
||||
gap: var(--inline-space-half);
|
||||
|
||||
@media (max-width: 479px) {
|
||||
flex-direction: column;
|
||||
|
||||
.btn {
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-perma__closure-message {
|
||||
color: var(--card-color);
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
}
|
||||
|
||||
.comment {
|
||||
display: flex;
|
||||
margin-inline: auto;
|
||||
max-inline-size: var(--comment-max);
|
||||
position: relative;
|
||||
@@ -34,6 +35,56 @@
|
||||
.house-md-content {
|
||||
padding: var(--comment-padding-block) 0 0;
|
||||
}
|
||||
|
||||
.comment-by-system & {
|
||||
--comment-padding-block: var(--block-space-half);
|
||||
|
||||
text-align: center;
|
||||
|
||||
&::before {
|
||||
/* Make up space for lack of avatar */
|
||||
content: "";
|
||||
display: flex;
|
||||
inline-size: calc(var(--comment-padding-inline) * 0.9);
|
||||
}
|
||||
|
||||
.comment__avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comment__author {
|
||||
a { margin: 0 auto; }
|
||||
h3 { margin-inline: auto; }
|
||||
strong { display: none; }
|
||||
}
|
||||
|
||||
.comment__body {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comment__content {
|
||||
--stripe-color: var(--color-ink-lightest);
|
||||
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg in srgb,
|
||||
var(--color-canvas) 0 1px,
|
||||
var(--stripe-color) 1px 10px);
|
||||
padding-inline: var(--comment-padding-inline);
|
||||
|
||||
.comments--system-expanded .comment-by-system & {
|
||||
--stripe-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.comment__history {
|
||||
background-color: var(--stripe-color);
|
||||
}
|
||||
}
|
||||
|
||||
.reactions {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.comment__author {
|
||||
@@ -73,6 +124,7 @@
|
||||
|
||||
background-color: var(--comment-bg-color);
|
||||
border-radius: 0.2em;
|
||||
max-inline-size: calc(100% - calc(var(--comment-padding-inline) * 0.75));
|
||||
padding:
|
||||
var(--comment-padding-block)
|
||||
calc(var(--comment-padding-inline) / 2)
|
||||
@@ -85,53 +137,36 @@
|
||||
background-color: var(--color-ink-lightest);
|
||||
}
|
||||
|
||||
.comment--system {
|
||||
--comment-padding-block: var(--block-space-half);
|
||||
.comment__history {
|
||||
background-color: var(--color-ink-lightest);
|
||||
display: none;
|
||||
inset: calc(var(--comment-padding-inline) / 2.5) calc(var(--comment-padding-inline) / 1.5) auto auto; margin-inline-end: calc(var(--comment-padding-inline) / -2.5);
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
max-inline-size: var(--comment-max);
|
||||
text-align: center;
|
||||
.comment-by-system {
|
||||
display: none;
|
||||
transition: var(--dialog-duration) allow-discrete;
|
||||
transition-property: display;
|
||||
|
||||
&::before {
|
||||
/* Make up space for lack of avatar */
|
||||
content: "";
|
||||
display: flex;
|
||||
inline-size: calc(var(--comment-padding-inline) * 0.75);
|
||||
.comments--system-expanded & {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
.comment__avatar {
|
||||
/* Show the last system comment */
|
||||
:nth-last-child(1 of .comment-by-system) {
|
||||
display: contents;
|
||||
|
||||
.comment__history {
|
||||
display: grid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the "Show history" button if there's only one system comment */
|
||||
:nth-child(1 of .comment-by-system) {
|
||||
.comment__history {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comment__author {
|
||||
a {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.comment__body {
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.comment__content {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg in srgb,
|
||||
var(--color-canvas) 0 1px,
|
||||
var(--color-ink-lightest) 1px 10px
|
||||
);
|
||||
padding-inline: var(--comment-padding-inline);
|
||||
}
|
||||
|
||||
.reactions {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,6 @@
|
||||
background-color: var(--color-canvas);
|
||||
grid-row-start: 1;
|
||||
inset-block-start: calc(var(--block-space) * -1);
|
||||
margin-inline: calc(var(--main-padding) * -1);
|
||||
margin-block-end: var(--events-gap);
|
||||
padding-block: calc(var(--events-gap) * 3) var(--events-gap);
|
||||
position: sticky;
|
||||
@@ -39,7 +39,6 @@
|
||||
|
||||
.header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--text-x-small);
|
||||
gap: var(--header-gap);
|
||||
inline-size: var(--header-actions-width);
|
||||
@@ -117,15 +116,17 @@
|
||||
/* Optional class to stack header actions on small screens
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.header--mobile-actions-stack {
|
||||
/* .header--mobile-actions-stack {
|
||||
@media (max-width: 639px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-areas:
|
||||
"actions-start menu actions-end"
|
||||
"title title title";
|
||||
"menu menu"
|
||||
"actions-start actions-end"
|
||||
"title title";
|
||||
|
||||
.header__title {
|
||||
margin-block-start: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
} */
|
||||
}
|
||||
@@ -59,6 +59,7 @@
|
||||
.icon--fizzy { --svg: url("fizzy.svg"); }
|
||||
.icon--globe { --svg: url("globe.svg "); }
|
||||
.icon--golden-ticket { --svg: url("golden-ticket.svg "); }
|
||||
.icon--history { --svg: url("history.svg "); }
|
||||
.icon--home { --svg: url("home.svg "); }
|
||||
.icon--install-edge { --svg: url("install-edge.svg "); }
|
||||
.icon--lifebuoy { --svg: url("lifebuoy.svg "); }
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
@layer platform {
|
||||
[data-platform~=ios] {
|
||||
.hide-on-ios {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@layer ios {
|
||||
.hide-in-ios-app {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
--opacity: 0.5;
|
||||
--size: 0.8em;
|
||||
|
||||
background: url("/link.svg") no-repeat center bottom 0.2em;
|
||||
background: url(link.svg) no-repeat center bottom 0.2em;
|
||||
background-size: var(--size);
|
||||
block-size: 1em;
|
||||
color: var(--color-link);
|
||||
@@ -1,9 +0,0 @@
|
||||
@layer mobile_app {
|
||||
.hide-in-mobile-app {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.show-in-mobile-app {
|
||||
display: unset;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
@layer mobile_app {
|
||||
/* Use custom insets to account for floating navigation elements.
|
||||
The mobile apps can inject their own inset value based on native elements on screen. */
|
||||
:root {
|
||||
@layer native {
|
||||
[data-platform~=native] {
|
||||
/* The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */
|
||||
--custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px));
|
||||
--custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px));
|
||||
--custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px));
|
||||
--custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px));
|
||||
|
||||
.hide-on-native {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.popup__footer {
|
||||
border-block-start: 1px solid var(--color-ink-lightest);
|
||||
color: var(--card-color);
|
||||
margin-block-start: var(--popup-item-padding-inline);
|
||||
padding: var(--popup-item-padding-inline) var(--popup-item-padding-inline) 0;
|
||||
text-align: center;
|
||||
text-transform: initial;
|
||||
}
|
||||
|
||||
.popup__title {
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
@@ -24,6 +24,10 @@
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
inset-inline-end: calc(var(--comment-padding-inline) / 3);
|
||||
}
|
||||
|
||||
.reactions__list {
|
||||
display: none;
|
||||
}
|
||||
+4
@@ -28,6 +28,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
p:has(+ p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
padding-inline-start: 3ch;
|
||||
}
|
||||
@@ -93,17 +93,14 @@
|
||||
}
|
||||
|
||||
.steps__icon {
|
||||
--icon-size: 0.875em;
|
||||
|
||||
background-color: var(--card-color);
|
||||
block-size: 1.3em;
|
||||
border-radius: 50%;
|
||||
color: var(--color-ink-inverted);
|
||||
display: grid;
|
||||
inline-size: 1.3em;
|
||||
place-content: center;
|
||||
|
||||
.icon {
|
||||
background-color: var(--color-ink-inverted);
|
||||
block-size: 0.8em;
|
||||
inline-size: 0.8em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,6 +424,7 @@
|
||||
.card__meta-text:not(.card__meta-text--updated),
|
||||
.card__stages,
|
||||
.card__steps,
|
||||
.card__comments,
|
||||
.card__closed {
|
||||
display: none;
|
||||
}
|
||||
@@ -184,6 +184,23 @@
|
||||
.fill-highlight { background-color: var(--color-highlight); }
|
||||
.fill-transparent { background-color: transparent; }
|
||||
|
||||
.fill-highlighter {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&::before {
|
||||
background-color: var(--color-highlight);
|
||||
border-radius: 0.2em;
|
||||
content: "";
|
||||
inset-block: 0;
|
||||
inset-inline: -0.1em;
|
||||
position: absolute;
|
||||
transform: skewX(-10deg) rotate(1deg);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.translucent { opacity: var(--opacity, 0.66); }
|
||||
|
||||
/* Borders */
|
||||
@@ -252,8 +269,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.show-in-mobile-app {
|
||||
display: none;
|
||||
.show-on-native {
|
||||
body:not([data-platform~=native]) & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-scrollbar {
|
||||
@@ -1,108 +0,0 @@
|
||||
@layer components {
|
||||
.icon {
|
||||
-webkit-touch-callout: none;
|
||||
background-color: currentColor;
|
||||
block-size: var(--icon-size, 1em);
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
inline-size: var(--icon-size, 1em);
|
||||
mask-image: var(--svg);
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: var(--icon-size, 1em);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
img.icon {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.icon--37signals { --svg: url("/37signals.svg"); }
|
||||
.icon--add { --svg: url("/add.svg"); }
|
||||
.icon--add--meta { --svg: url("/add--meta.svg"); }
|
||||
.icon--arrow-left { --svg: url("/arrow-left.svg"); }
|
||||
.icon--arrow-right { --svg: url("/arrow-right.svg"); }
|
||||
.icon--arrow-up { --svg: url("/arrow-up.svg"); }
|
||||
.icon--art { --svg: url("/art.svg"); }
|
||||
.icon--assigned { --svg: url("/assigned.svg"); }
|
||||
.icon--attachment { --svg: url("/attachment.svg"); }
|
||||
.icon--bell-alert { --svg: url("/bell-alert.svg"); }
|
||||
.icon--bell-off { --svg: url("/bell-off.svg"); }
|
||||
.icon--bell { --svg: url("/bell.svg"); }
|
||||
.icon--bolt { --svg: url("/bolt.svg"); }
|
||||
.icon--bookmark-outline { --svg: url("/bookmark-outline.svg"); }
|
||||
.icon--bookmark { --svg: url("/bookmark.svg"); }
|
||||
.icon--boost { --svg: url("/boost.svg"); }
|
||||
.icon--camera { --svg: url("/camera.svg"); }
|
||||
.icon--caret-down { --svg: url("/caret-down.svg"); }
|
||||
.icon--check { --svg: url("/check.svg"); }
|
||||
.icon--check-circle { --svg: url("/check-circle.svg"); }
|
||||
.icon--check-all { --svg: url("/check-all.svg"); }
|
||||
.icon--clipboard { --svg: url("/clipboard.svg"); }
|
||||
.icon--close { --svg: url("/close.svg"); }
|
||||
.icon--close-circle { --svg: url("/close-circle.svg"); }
|
||||
.icon--collapse { --svg: url("/collapse.svg"); }
|
||||
.icon--board { --svg: url("/board.svg"); }
|
||||
.icon--board-add { --svg: url("/board-add.svg"); }
|
||||
.icon--column-left { --svg: url("/column-left.svg"); }
|
||||
.icon--column-right { --svg: url("/column-right.svg"); }
|
||||
.icon--comment { --svg: url("/comment.svg"); }
|
||||
.icon--copy-paste { --svg: url("/copy-paste.svg"); }
|
||||
.icon--crown { --svg: url("/crown.svg"); }
|
||||
.icon--email { --svg: url("/email.svg"); }
|
||||
.icon--everyone { --svg: url("/everyone.svg"); }
|
||||
.icon--expand { --svg: url("/expand.svg"); }
|
||||
.icon--gear { --svg: url("/gear.svg"); }
|
||||
.icon--grid { --svg: url("/grid.svg"); }
|
||||
.icon--filter { --svg: url("/filter.svg"); }
|
||||
.icon--fizzy { --svg: url("/fizzy.svg"); }
|
||||
.icon--globe { --svg: url("/globe.svg"); }
|
||||
.icon--golden-ticket { --svg: url("/golden-ticket.svg"); }
|
||||
.icon--home { --svg: url("/home.svg"); }
|
||||
.icon--install { --svg: url("/install.svg"); }
|
||||
.icon--install { --svg: url("/install.svg"); }
|
||||
.icon--install-edge { --svg: url("/install-edge.svg"); }
|
||||
.icon--lifebuoy { --svg: url("/lifebuoy.svg"); }
|
||||
.icon--lock { --svg: url("/lock.svg"); }
|
||||
.icon--logout { --svg: url("/logout.svg"); }
|
||||
.icon--marker { --svg: url("/marker.svg"); }
|
||||
.icon--maximize { --svg: url("/maximize.svg"); }
|
||||
.icon--menu { --svg: url("/menu.svg"); }
|
||||
.icon--menu-dots-horizontal { --svg: url("/menu-dots-horizontal.svg"); }
|
||||
.icon--menu-dots-vertical { --svg: url("/menu-dots-vertical.svg"); }
|
||||
.icon--minus { --svg: url("/minus.svg"); }
|
||||
.icon--move { --svg: url("/move.svg"); }
|
||||
.icon--notification-bell-access-only { --svg: url("/bell.svg"); }
|
||||
.icon--notification-bell-watching { --svg: url("/bell-off.svg"); }
|
||||
.icon--notification-bell-reverse-access-only { --svg: url("/bell-off.svg"); }
|
||||
.icon--notification-bell-reverse-watching { --svg: url("/bell.svg"); }
|
||||
.icon--password { --svg: url("/password.svg"); }
|
||||
.icon--pencil { --svg: url("/pencil.svg"); }
|
||||
.icon--person { --svg: url("/person.svg"); }
|
||||
.icon--person-add { --svg: url("/person-add.svg"); }
|
||||
.icon--picture-add { --svg: url("/picture-add.svg"); }
|
||||
.icon--picture-double { --svg: url("/picture-double.svg"); }
|
||||
.icon--picture-remove { --svg: url("/picture-remove.svg"); }
|
||||
.icon--picture-zoom { --svg: url("/picture-zoom.svg"); }
|
||||
.icon--pinned { --svg: url("/pinned.svg"); }
|
||||
.icon--qr-code { --svg: url("/qr-code.svg"); }
|
||||
.icon--reaction { --svg: url("/reaction.svg"); }
|
||||
.icon--refresh { --svg: url("/refresh.svg"); }
|
||||
.icon--refresh--meta { --svg: url("/refresh--meta.svg"); }
|
||||
.icon--remove { --svg: url("/remove.svg"); }
|
||||
.icon--rename { --svg: url("/rename.svg"); }
|
||||
.icon--search { --svg: url("/search.svg"); }
|
||||
.icon--settings { --svg: url("/settings.svg"); }
|
||||
.icon--share { --svg: url("/share.svg"); }
|
||||
.icon--sliders { --svg: url("/sliders.svg"); }
|
||||
.icon--switch { --svg: url("/switch.svg"); }
|
||||
.icon--tag { --svg: url("/tag.svg"); }
|
||||
.icon--tag-outline { --svg: url("/tag-outline.svg"); }
|
||||
.icon--thumb-up { --svg: url("/thumb-up.svg"); }
|
||||
.icon--trash { --svg: url("/trash.svg"); }
|
||||
.icon--unpinned { --svg: url("/unpinned.svg"); }
|
||||
.icon--unseen { --svg: url("/unseen.svg"); }
|
||||
.icon--world { --svg: url("/world.svg"); }
|
||||
.icon--youtube { --svg: url("/youtube.svg"); }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@layer components {
|
||||
.welcome-letter {
|
||||
position: relative;
|
||||
view-transition-name: welcome-letter;
|
||||
z-index: var(--z-welcome);
|
||||
|
||||
h2, p {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-letter__close {
|
||||
inset: var(--block-space) var(--block-space) auto auto;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.welcome-letter__signature {
|
||||
background-color: currentColor;
|
||||
block-size: 3em;
|
||||
display: inline-block;
|
||||
inline-size: 8em;
|
||||
mask-image: url("jf-signature.svg");
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: 8em 3em;
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ class BoardsController < ApplicationController
|
||||
def show_columns
|
||||
cards = @board.cards.awaiting_triage.latest.with_golden_first.preloaded
|
||||
set_page_and_extract_portion_from cards
|
||||
fresh_when etag: [ @board, @page.records, @user_filtering ]
|
||||
fresh_when etag: [ @board, @page.records, @user_filtering, Current.account ]
|
||||
end
|
||||
|
||||
def board_params
|
||||
|
||||
@@ -8,11 +8,16 @@ class Cards::AssignmentsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
@card.toggle_assignment @board.users.active.find(params[:assignee_id])
|
||||
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.json { head :no_content }
|
||||
if @card.toggle_assignment @board.users.active.find(params[:assignee_id])
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.json { head :no_content }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.turbo_stream
|
||||
format.json { head :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -61,6 +61,6 @@ class CardsController < ApplicationController
|
||||
end
|
||||
|
||||
def card_params
|
||||
params.expect(card: [ :status, :title, :description, :image, :created_at, :last_active_at, tag_ids: [] ])
|
||||
params.expect(card: [ :title, :description, :image, :created_at, :last_active_at ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,13 +4,12 @@ module Authentication
|
||||
included do
|
||||
before_action :require_account # Checking and setting account must happen first
|
||||
before_action :require_authentication
|
||||
after_action :ensure_development_magic_link_not_leaked
|
||||
helper_method :authenticated?
|
||||
helper_method :email_address_pending_authentication
|
||||
|
||||
etag { Current.identity.id if authenticated? }
|
||||
|
||||
include LoginHelper
|
||||
include Authentication::ViaMagicLink, LoginHelper
|
||||
end
|
||||
|
||||
class_methods do
|
||||
@@ -102,35 +101,7 @@ module Authentication
|
||||
cookies.delete(:session_token)
|
||||
end
|
||||
|
||||
def ensure_development_magic_link_not_leaked
|
||||
unless Rails.env.development?
|
||||
raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present?
|
||||
end
|
||||
end
|
||||
|
||||
def email_address_pending_authentication_matches?(email_address)
|
||||
if ActiveSupport::SecurityUtils.secure_compare(email_address, email_address_pending_authentication || "")
|
||||
session.delete(:email_address_pending_authentication)
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def email_address_pending_authentication
|
||||
session[:email_address_pending_authentication]
|
||||
end
|
||||
|
||||
def redirect_to_session_magic_link(magic_link, return_to: nil)
|
||||
serve_development_magic_link(magic_link)
|
||||
session[:email_address_pending_authentication] = magic_link.identity.email_address if magic_link
|
||||
session[:return_to_after_authenticating] = return_to if return_to
|
||||
redirect_to main_app.session_magic_link_path(script_name: nil)
|
||||
end
|
||||
|
||||
def serve_development_magic_link(magic_link)
|
||||
if Rails.env.development?
|
||||
flash[:magic_link_code] = magic_link&.code
|
||||
end
|
||||
def session_token
|
||||
cookies[:session_token]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
module Authentication::ViaMagicLink
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_action :ensure_development_magic_link_not_leaked
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_development_magic_link_not_leaked
|
||||
unless Rails.env.development?
|
||||
raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present?
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_to_fake_session_magic_link(email_address, **options)
|
||||
fake_magic_link = MagicLink.new(
|
||||
identity: Identity.new(email_address: email_address),
|
||||
code: SecureRandom.base32(6),
|
||||
expires_at: MagicLink::EXPIRATION_TIME.from_now
|
||||
)
|
||||
|
||||
redirect_to_session_magic_link fake_magic_link, **options
|
||||
end
|
||||
|
||||
def redirect_to_session_magic_link(magic_link, return_to: nil)
|
||||
serve_development_magic_link(magic_link)
|
||||
set_pending_authentication_token(magic_link)
|
||||
session[:return_to_after_authenticating] = return_to if return_to
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to main_app.session_magic_link_url(script_name: nil) }
|
||||
format.json { render json: { pending_authentication_token: pending_authentication_token }, status: :created }
|
||||
end
|
||||
end
|
||||
|
||||
def serve_development_magic_link(magic_link)
|
||||
if Rails.env.development? && magic_link.present?
|
||||
flash[:magic_link_code] = magic_link.code
|
||||
response.set_header("X-Magic-Link-Code", magic_link.code)
|
||||
end
|
||||
end
|
||||
|
||||
def set_pending_authentication_token(magic_link)
|
||||
cookies[:pending_authentication_token] = {
|
||||
value: pending_authentication_token_verifier.generate(magic_link.identity.email_address, expires_at: magic_link.expires_at),
|
||||
httponly: true,
|
||||
same_site: :lax,
|
||||
expires: magic_link.expires_at
|
||||
}
|
||||
end
|
||||
|
||||
def email_address_pending_authentication
|
||||
pending_authentication_token_verifier.verified(pending_authentication_token)
|
||||
end
|
||||
|
||||
def pending_authentication_token_verifier
|
||||
Rails.application.message_verifier(:pending_authentication)
|
||||
end
|
||||
|
||||
def pending_authentication_token
|
||||
cookies[:pending_authentication_token]
|
||||
end
|
||||
|
||||
def clear_pending_authentication_token
|
||||
cookies.delete(:pending_authentication_token)
|
||||
end
|
||||
end
|
||||
@@ -16,7 +16,7 @@ module FilterScoped
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.reverse_merge(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS)
|
||||
params.with_defaults(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS)
|
||||
end
|
||||
|
||||
def set_user_filtering
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
class LandingsController < ApplicationController
|
||||
def show
|
||||
flash.keep(:welcome_letter)
|
||||
|
||||
if Current.user.boards.one?
|
||||
redirect_to board_path(Current.user.boards.first)
|
||||
else
|
||||
|
||||
@@ -4,7 +4,8 @@ class My::MenusController < ApplicationController
|
||||
@boards = Current.user.boards.ordered_by_recently_accessed
|
||||
@tags = Current.account.tags.all.alphabetically
|
||||
@users = Current.account.users.active.alphabetically
|
||||
@accounts = Current.identity.accounts
|
||||
|
||||
fresh_when etag: [ @filters, @boards, @tags, @users ]
|
||||
fresh_when etag: [ @filters, @boards, @tags, @users, @accounts ]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Sessions::MagicLinksController < ApplicationController
|
||||
disallow_account_scope
|
||||
require_unauthenticated_access
|
||||
rate_limit to: 10, within: 15.minutes, only: :create, with: -> { redirect_to session_magic_link_path, alert: "Wait 15 minutes, then try again" }
|
||||
rate_limit to: 10, within: 15.minutes, only: :create, with: :rate_limit_exceeded
|
||||
before_action :ensure_that_email_address_pending_authentication_exists
|
||||
|
||||
layout "public"
|
||||
@@ -11,25 +11,20 @@ class Sessions::MagicLinksController < ApplicationController
|
||||
|
||||
def create
|
||||
if magic_link = MagicLink.consume(code)
|
||||
authenticate_with magic_link
|
||||
authenticate magic_link
|
||||
else
|
||||
redirect_to session_magic_link_path, flash: { shake: true }
|
||||
invalid_code
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_that_email_address_pending_authentication_exists
|
||||
unless email_address_pending_authentication.present?
|
||||
redirect_to new_session_path, alert: "Enter your email address to sign in."
|
||||
end
|
||||
end
|
||||
|
||||
def authenticate_with(magic_link)
|
||||
if email_address_pending_authentication_matches?(magic_link.identity.email_address)
|
||||
start_new_session_for magic_link.identity
|
||||
redirect_to after_sign_in_url(magic_link)
|
||||
else
|
||||
redirect_to new_session_path, alert: "Authentication failed. Please try again."
|
||||
alert_message = "Enter your email address to sign in."
|
||||
respond_to do |format|
|
||||
format.html { redirect_to new_session_path, alert: alert_message }
|
||||
format.json { render json: { message: alert_message }, status: :unauthorized }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,6 +32,41 @@ class Sessions::MagicLinksController < ApplicationController
|
||||
params.expect(:code)
|
||||
end
|
||||
|
||||
def authenticate(magic_link)
|
||||
if ActiveSupport::SecurityUtils.secure_compare(email_address_pending_authentication || "", magic_link.identity.email_address)
|
||||
sign_in magic_link
|
||||
else
|
||||
email_address_mismatch
|
||||
end
|
||||
end
|
||||
|
||||
def sign_in(magic_link)
|
||||
clear_pending_authentication_token
|
||||
start_new_session_for magic_link.identity
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to after_sign_in_url(magic_link) }
|
||||
format.json { render json: { session_token: session_token } }
|
||||
end
|
||||
end
|
||||
|
||||
def email_address_mismatch
|
||||
clear_pending_authentication_token
|
||||
alert_message = "Something went wrong. Please try again."
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to new_session_path, alert: alert_message }
|
||||
format.json { render json: { message: alert_message }, status: :unauthorized }
|
||||
end
|
||||
end
|
||||
|
||||
def invalid_code
|
||||
respond_to do |format|
|
||||
format.html { redirect_to session_magic_link_path, flash: { shake: true } }
|
||||
format.json { render json: { message: "Try another code." }, status: :unauthorized }
|
||||
end
|
||||
end
|
||||
|
||||
def after_sign_in_url(magic_link)
|
||||
if magic_link.for_sign_up?
|
||||
new_signup_completion_path
|
||||
@@ -44,4 +74,12 @@ class Sessions::MagicLinksController < ApplicationController
|
||||
after_authentication_url
|
||||
end
|
||||
end
|
||||
|
||||
def rate_limit_exceeded
|
||||
rate_limit_exceeded_message = "Try again in 15 minutes."
|
||||
respond_to do |format|
|
||||
format.html { redirect_to session_magic_link_path, alert: rate_limit_exceeded_message }
|
||||
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class SessionsController < ApplicationController
|
||||
disallow_account_scope
|
||||
require_unauthenticated_access except: :destroy
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
|
||||
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
|
||||
|
||||
layout "public"
|
||||
|
||||
@@ -9,16 +9,12 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
if identity = Identity.find_by_email_address(email_address)
|
||||
redirect_to_session_magic_link identity.send_magic_link
|
||||
if identity = Identity.find_by(email_address: email_address)
|
||||
sign_in identity
|
||||
elsif Account.accepting_signups?
|
||||
sign_up
|
||||
else
|
||||
signup = Signup.new(email_address: email_address)
|
||||
if signup.valid?(:identity_creation)
|
||||
magic_link = signup.create_identity if Account.accepting_signups?
|
||||
redirect_to_session_magic_link magic_link
|
||||
else
|
||||
head :unprocessable_entity
|
||||
end
|
||||
redirect_to_fake_session_magic_link email_address
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,7 +24,43 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def magic_link_from_sign_in_or_sign_up
|
||||
if identity = Identity.find_by_email_address(email_address)
|
||||
identity.send_magic_link
|
||||
else
|
||||
signup = Signup.new(email_address: email_address)
|
||||
signup.create_identity if signup.valid?(:identity_creation) && Account.accepting_signups?
|
||||
end
|
||||
end
|
||||
|
||||
def email_address
|
||||
params.expect(:email_address)
|
||||
end
|
||||
|
||||
def rate_limit_exceeded
|
||||
rate_limit_exceeded_message = "Try again later."
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message }
|
||||
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
|
||||
end
|
||||
end
|
||||
|
||||
def sign_in(identity)
|
||||
redirect_to_session_magic_link identity.send_magic_link
|
||||
end
|
||||
|
||||
def sign_up
|
||||
signup = Signup.new(email_address: email_address)
|
||||
|
||||
if signup.valid?(:identity_creation)
|
||||
magic_link = signup.create_identity
|
||||
redirect_to_session_magic_link magic_link
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html { redirect_to new_session_path, alert: "Something went wrong" }
|
||||
format.json { render json: { message: "Something went wrong" }, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ class Signups::CompletionsController < ApplicationController
|
||||
@signup = Signup.new(signup_params)
|
||||
|
||||
if @signup.complete
|
||||
flash[:welcome_letter] = true
|
||||
redirect_to landing_url(script_name: @signup.account.slug)
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
module ApplicationHelper
|
||||
def stylesheet_link_tags
|
||||
stylesheet_link_tag *platform.stylesheet_paths, "data-turbo-track": "reload"
|
||||
end
|
||||
|
||||
def page_title_tag
|
||||
account_name = if Current.account && Current.session&.identity&.users&.many?
|
||||
Current.account&.name
|
||||
@@ -14,12 +10,6 @@ module ApplicationHelper
|
||||
tag.span class: class_names("icon icon--#{name}", options.delete(:class)), "aria-hidden": true, **options
|
||||
end
|
||||
|
||||
def inline_svg(name)
|
||||
file_path = "#{Rails.root}/app/assets/images/#{name}.svg"
|
||||
return File.read(file_path).html_safe if File.exist?(file_path)
|
||||
"(not found)"
|
||||
end
|
||||
|
||||
def back_link_to(label, url, action, **options)
|
||||
link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do
|
||||
icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe
|
||||
|
||||
@@ -43,7 +43,7 @@ module ColumnsHelper
|
||||
end
|
||||
|
||||
def column_frame_tag(id, src: nil, data: {}, **options, &block)
|
||||
data = data.reverse_merge \
|
||||
data = data.with_defaults \
|
||||
drag_and_drop_refresh: true,
|
||||
controller: "frame",
|
||||
action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload"
|
||||
|
||||
@@ -3,6 +3,8 @@ module MessagesHelper
|
||||
turbo_frame_tag dom_id(card, :messages),
|
||||
class: "comments gap center",
|
||||
style: "--card-color: #{card.color}",
|
||||
role: "group", aria: { label: "Messages" }, &
|
||||
role: "group",
|
||||
aria: { label: "Messages" },
|
||||
data: { controller: "toggle-class", toggle_class_toggle_class: "comments--system-expanded" }, &
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { limit: Number, count: Number }
|
||||
static targets = ["unassigned", "limitMessage"]
|
||||
|
||||
connect() {
|
||||
this.updateState()
|
||||
}
|
||||
|
||||
countValueChanged() {
|
||||
this.updateState()
|
||||
}
|
||||
|
||||
updateState() {
|
||||
const atLimit = this.countValue >= this.limitValue
|
||||
|
||||
this.unassignedTargets.forEach(el => {
|
||||
el.hidden = atLimit
|
||||
})
|
||||
|
||||
if (this.hasLimitMessageTarget) {
|
||||
this.limitMessageTarget.hidden = !atLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ export default class extends Controller {
|
||||
static targets = [ "dialog" ]
|
||||
static values = {
|
||||
modal: { type: Boolean, default: false },
|
||||
sizing: { type: Boolean, default: true }
|
||||
sizing: { type: Boolean, default: true },
|
||||
autoOpen: { type: Boolean, default: false }
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.dialogTarget.setAttribute("aria-hidden", "true")
|
||||
if (this.autoOpenValue) this.open()
|
||||
}
|
||||
|
||||
open() {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
class Storage::ReconcileJob < ApplicationJob
|
||||
class ReconcileAborted < StandardError; end
|
||||
|
||||
queue_as :backend
|
||||
limits_concurrency to: 1, key: ->(owner) { owner }
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
retry_on ReconcileAborted, wait: 1.minute, attempts: 3
|
||||
|
||||
def perform(owner)
|
||||
owner.reconcile_storage
|
||||
raise ReconcileAborted, "Could not get stable snapshot for #{owner.class}##{owner.id}" unless owner.reconcile_storage
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ class Account < ApplicationRecord
|
||||
def create_with_owner(account:, owner:)
|
||||
create!(**account).tap do |account|
|
||||
account.users.create!(role: :system, name: "System")
|
||||
account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current))
|
||||
account.users.create!(**owner.with_defaults(role: :owner, verified_at: Time.current))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,6 +69,7 @@ class Account::Seeder
|
||||
<li>Make another called "Working on"</li>
|
||||
</ol>
|
||||
<p>Go back to the Board view, click the little “+” to the right of the DONE column, name the column, pick a color, then do it again.</p>
|
||||
<p><br></p>
|
||||
<p>After that, drag this card to “DONE” or select “DONE” in the sidebar.</p>
|
||||
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/make-columns.gif" alt="Demo of adding columns" caption="Make two more columns" content-type="image/*" filename="make-columns.gif" presentation="gallery"></action-text-attachment>
|
||||
HTML
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
module Admin
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
class ApplicationPlatform < PlatformAgent
|
||||
SCOPED_STYLESHEET_PATHS = {}
|
||||
|
||||
def ios?
|
||||
match? /iPhone|iPad/
|
||||
end
|
||||
@@ -37,20 +35,24 @@ class ApplicationPlatform < PlatformAgent
|
||||
!mobile?
|
||||
end
|
||||
|
||||
def native?
|
||||
match? /Hotwire Native/
|
||||
end
|
||||
|
||||
def windows?
|
||||
operating_system == "Windows"
|
||||
end
|
||||
|
||||
def ios_app?
|
||||
match? /Fizzy iOS/
|
||||
end
|
||||
|
||||
def android_app?
|
||||
match? /Fizzy Android/
|
||||
end
|
||||
|
||||
def mobile_app?
|
||||
ios_app? || android_app?
|
||||
def type
|
||||
if native? && android?
|
||||
"native android"
|
||||
elsif native? && ios?
|
||||
"native ios"
|
||||
elsif mobile?
|
||||
"mobile web"
|
||||
else
|
||||
"desktop web"
|
||||
end
|
||||
end
|
||||
|
||||
def operating_system
|
||||
@@ -65,31 +67,4 @@ class ApplicationPlatform < PlatformAgent
|
||||
os =~ /Linux/ ? "Linux" : os
|
||||
end
|
||||
end
|
||||
|
||||
def stylesheet_paths
|
||||
scoped_stylesheet_paths("web") +
|
||||
(mobile_app? ? scoped_stylesheet_paths("mobile_app") : []) +
|
||||
scoped_stylesheet_paths(stylesheet_asset_name)
|
||||
end
|
||||
|
||||
private
|
||||
def stylesheet_asset_name
|
||||
case
|
||||
when android_app? then "android"
|
||||
when ios_app? then "ios"
|
||||
else "desktop"
|
||||
end
|
||||
end
|
||||
|
||||
def scoped_stylesheet_paths(scope = css_asset_name)
|
||||
# Allow new stylesheets to be added in dev/test without restarting server
|
||||
SCOPED_STYLESHEET_PATHS.clear if Rails.env.development?
|
||||
|
||||
SCOPED_STYLESHEET_PATHS[scope] ||=
|
||||
Rails.root.join("app/assets/stylesheets").then do |stylesheet_root|
|
||||
stylesheet_root.glob("#{scope}/**/*.css").collect do |path|
|
||||
path.to_s.remove(stylesheet_root.to_s + "/", ".css")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
class Assignment < ApplicationRecord
|
||||
LIMIT = 100
|
||||
|
||||
belongs_to :account, default: -> { card.account }
|
||||
belongs_to :card, touch: true
|
||||
|
||||
belongs_to :assignee, class_name: "User"
|
||||
belongs_to :assigner, class_name: "User"
|
||||
|
||||
validate :within_limit, on: :create
|
||||
|
||||
private
|
||||
def within_limit
|
||||
if card.assignments.count >= LIMIT
|
||||
errors.add(:base, "Card already has the maximum of #{LIMIT} assignees")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,25 +12,32 @@ module Board::Storage
|
||||
|
||||
# Calculate actual storage by summing blob sizes.
|
||||
#
|
||||
# Uses batched pluck queries to avoid loading huge ID arrays, and avoids
|
||||
# ActiveRecord model queries on ActiveStorage tables to sidestep cross-pool
|
||||
# issues when ActiveStorage uses separate connection pools (e.g., with replicas).
|
||||
# Storage tracking is a business abstraction - we count what users upload.
|
||||
# Original upload bytes only; variants/previews/derivatives excluded.
|
||||
# Physical storage optimizations (deduplication, compression) don't affect quotas.
|
||||
def calculate_real_storage_bytes
|
||||
@card_ids = nil # Clear memoization for fresh calculation
|
||||
card_image_bytes + card_embed_bytes + comment_embed_bytes + board_embed_bytes
|
||||
end
|
||||
|
||||
def card_ids
|
||||
@card_ids ||= cards.pluck(:id)
|
||||
end
|
||||
|
||||
def card_image_bytes
|
||||
sum_blob_bytes_in_batches \
|
||||
ActiveStorage::Attachment.where(record_type: "Card", name: "image"),
|
||||
cards.pluck(:id)
|
||||
card_ids
|
||||
end
|
||||
|
||||
def card_embed_bytes
|
||||
sum_embed_bytes_for "Card", cards.pluck(:id)
|
||||
sum_embed_bytes_for "Card", card_ids
|
||||
end
|
||||
|
||||
def comment_embed_bytes
|
||||
sum_embed_bytes_for "Comment", Comment.where(card_id: cards.pluck(:id)).pluck(:id)
|
||||
card_ids.each_slice(BATCH_SIZE).sum do |batch|
|
||||
sum_embed_bytes_for "Comment", Comment.where(card_id: batch).pluck(:id)
|
||||
end
|
||||
end
|
||||
|
||||
def board_embed_bytes
|
||||
@@ -48,9 +55,18 @@ module Board::Storage
|
||||
end
|
||||
|
||||
def sum_blob_bytes_in_batches(base_scope, record_ids)
|
||||
# Count per-attachment to match ledger model.
|
||||
# Same blob attached 3 times = 3x bytes (business abstraction, not physical storage).
|
||||
#
|
||||
# Do NOT remove the join thinking it's a performance optimization - it's required
|
||||
# for correct per-attachment counting. We keep ActiveStorage/ActionText in the same
|
||||
# database (realm/geo partitioning, not functionality partitioning), so cross-table
|
||||
# joins are fine.
|
||||
record_ids.each_slice(BATCH_SIZE).sum do |batch_ids|
|
||||
blob_ids = base_scope.where(record_id: batch_ids).pluck(:blob_id)
|
||||
ActiveStorage::Blob.where(id: blob_ids).sum(:byte_size)
|
||||
base_scope
|
||||
.where(record_id: batch_ids)
|
||||
.joins(:blob)
|
||||
.sum("active_storage_blobs.byte_size")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -68,6 +68,55 @@ 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
|
||||
|
||||
@@ -24,10 +24,12 @@ module Card::Assignable
|
||||
|
||||
private
|
||||
def assign(user)
|
||||
assignments.create! assignee: user, assigner: Current.user
|
||||
watch_by user
|
||||
assignment = assignments.create assignee: user, assigner: Current.user
|
||||
|
||||
track_event :assigned, assignee_ids: [ user.id ]
|
||||
if assignment.persisted?
|
||||
watch_by user
|
||||
track_event :assigned, assignee_ids: [ user.id ]
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
# Already assigned
|
||||
end
|
||||
|
||||
@@ -6,8 +6,6 @@ module Card::Statuses
|
||||
|
||||
before_save :mark_if_just_published
|
||||
after_create -> { track_event :published }, if: :published?
|
||||
|
||||
scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) }
|
||||
end
|
||||
|
||||
attr_accessor :was_just_published
|
||||
|
||||
@@ -19,7 +19,7 @@ module Storage::Totaled
|
||||
|
||||
# Exact: snapshot + pending entries
|
||||
def bytes_used_exact
|
||||
(storage_total || create_storage_total!).current_usage
|
||||
create_or_find_storage_total.current_usage
|
||||
end
|
||||
|
||||
def materialize_storage_later
|
||||
@@ -28,7 +28,7 @@ module Storage::Totaled
|
||||
|
||||
# Materialize all pending entries into snapshot
|
||||
def materialize_storage
|
||||
total = storage_total || create_storage_total!
|
||||
total = create_or_find_storage_total
|
||||
|
||||
total.with_lock do
|
||||
latest_entry_id = storage_entries.maximum(:id)
|
||||
@@ -44,26 +44,44 @@ module Storage::Totaled
|
||||
end
|
||||
|
||||
# Reconcile ledger against actual attachment storage.
|
||||
# Uses cursor to ensure consistency: captures max entry ID first, then calculates
|
||||
# real bytes, then sums only entries up to that cursor. Concurrent uploads during
|
||||
# calculation will have entries with IDs beyond the cursor, avoiding double-count.
|
||||
#
|
||||
# Uses two-cursor approach for consistency: capture cursor before AND after the
|
||||
# scan. If they differ, entries were added during the scan and we can't get an
|
||||
# accurate diff without risking double-counting or undercounting.
|
||||
#
|
||||
# Returns true if reconciled successfully, false if aborted due to concurrent
|
||||
# writes. Caller (ReconcileJob) handles retries to avoid amplification.
|
||||
def reconcile_storage
|
||||
max_entry_id = storage_entries.maximum(:id)
|
||||
cursor_before = storage_entries.maximum(:id)
|
||||
real_bytes = calculate_real_storage_bytes
|
||||
ledger_bytes = max_entry_id ? storage_entries.where(id: ..max_entry_id).sum(:delta) : 0
|
||||
diff = real_bytes - ledger_bytes
|
||||
cursor_after = storage_entries.maximum(:id)
|
||||
|
||||
if diff.nonzero?
|
||||
Storage::Entry.record \
|
||||
account: is_a?(Account) ? self : account,
|
||||
board: is_a?(Board) ? self : nil,
|
||||
recordable: nil,
|
||||
delta: diff,
|
||||
operation: "reconcile"
|
||||
if cursor_before != cursor_after
|
||||
Rails.logger.warn "[Storage] Reconcile aborted for #{self.class}##{id}: cursor moved during scan"
|
||||
false
|
||||
else
|
||||
ledger_bytes = cursor_after ? storage_entries.where(id: ..cursor_after).sum(:delta) : 0
|
||||
diff = real_bytes - ledger_bytes
|
||||
|
||||
if diff.nonzero?
|
||||
Rails.logger.info "[Storage] Reconcile #{self.class}##{id}: adjusting by #{diff} bytes"
|
||||
Storage::Entry.record \
|
||||
account: is_a?(Account) ? self : account,
|
||||
board: is_a?(Board) ? self : nil,
|
||||
recordable: nil,
|
||||
delta: diff,
|
||||
operation: "reconcile"
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def create_or_find_storage_total
|
||||
self.storage_total ||= Storage::Total.create_or_find_by!(owner: self)
|
||||
end
|
||||
|
||||
def calculate_real_storage_bytes
|
||||
raise NotImplementedError, "Subclass must implement calculate_real_storage_bytes"
|
||||
end
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Storage tracking is a business abstraction - we count what users upload.
|
||||
# Original upload bytes only; variants/previews/derivatives excluded.
|
||||
# Physical storage optimizations (deduplication, compression) don't affect quotas.
|
||||
module Storage::Tracked
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
@@ -22,21 +25,27 @@ module Storage::Tracked
|
||||
|
||||
private
|
||||
def board_transfer?
|
||||
respond_to?(:board_id_changed?) && board_id_changed?
|
||||
respond_to?(:will_save_change_to_board_id?) && will_save_change_to_board_id?
|
||||
end
|
||||
|
||||
def track_board_transfer
|
||||
old_board_id = board_id_was
|
||||
current_bytes = storage_bytes
|
||||
old_board = Board.find_by(id: attribute_in_database(:board_id))
|
||||
records = storage_transfer_records.compact
|
||||
return if records.empty?
|
||||
|
||||
attachments_by_record = storage_attachments_for_records(records)
|
||||
|
||||
attachments_by_record.each do |recordable, attachments|
|
||||
bytes = attachments.sum { |attachment| attachment.blob.byte_size }
|
||||
next if bytes.zero?
|
||||
|
||||
if current_bytes.positive?
|
||||
# Debit old board
|
||||
if old_board_id
|
||||
if old_board
|
||||
Storage::Entry.record \
|
||||
account: account,
|
||||
board_id: old_board_id,
|
||||
recordable: self,
|
||||
delta: -current_bytes,
|
||||
board: old_board,
|
||||
recordable: recordable,
|
||||
delta: -bytes,
|
||||
operation: "transfer_out"
|
||||
end
|
||||
|
||||
@@ -44,14 +53,45 @@ module Storage::Tracked
|
||||
Storage::Entry.record \
|
||||
account: account,
|
||||
board: board,
|
||||
recordable: self,
|
||||
delta: current_bytes,
|
||||
recordable: recordable,
|
||||
delta: bytes,
|
||||
operation: "transfer_in"
|
||||
end
|
||||
end
|
||||
|
||||
def storage_transfer_records
|
||||
[ self ]
|
||||
end
|
||||
|
||||
# Override if needed. Default = all direct attachments
|
||||
def attachments_for_storage
|
||||
ActiveStorage::Attachment.where(record: self)
|
||||
def attachments_for_storage(recordable = self)
|
||||
storage_attachments_for_records([ recordable ]).fetch(recordable, [])
|
||||
end
|
||||
|
||||
def storage_attachments_for_records(recordables)
|
||||
records = Array(recordables).compact
|
||||
return {} if records.empty?
|
||||
|
||||
# Build lookup for records by (type, id) to avoid N+1 when resolving RichText parents
|
||||
records_by_key = records.index_by { |r| [ r.class.name, r.id ] }
|
||||
|
||||
rich_texts = ActionText::RichText.where(record: records)
|
||||
rich_text_to_parent = rich_texts.to_h { |rt| [ rt.id, records_by_key[[ rt.record_type, rt.record_id ]] ] }
|
||||
|
||||
attachments = ActiveStorage::Attachment
|
||||
.where(record: records + rich_texts)
|
||||
.includes(:blob)
|
||||
.to_a
|
||||
|
||||
attachments.each_with_object(Hash.new { |h, k| h[k] = [] }) do |attachment, grouped|
|
||||
# Resolve parent without N+1: use lookup for RichText, direct for others
|
||||
recordable = if attachment.record_type == "ActionText::RichText"
|
||||
rich_text_to_parent[attachment.record_id]
|
||||
else
|
||||
records_by_key[[ attachment.record_type, attachment.record_id ]]
|
||||
end
|
||||
|
||||
grouped[recordable] << attachment if recordable
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ class Event::Description
|
||||
end
|
||||
|
||||
def to_plain_text
|
||||
to_sentence(creator_name, card.title)
|
||||
to_sentence(creator_name, h(card.title))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -2,4 +2,17 @@ module Storage
|
||||
def self.table_name_prefix
|
||||
"storage_"
|
||||
end
|
||||
|
||||
# Record types that participate in storage tracking (ledger entries created on attach).
|
||||
# The no-reuse validation uses this to scope its check.
|
||||
#
|
||||
# IMPORTANT: Update this constant when adding tracked attachments to new models.
|
||||
# If you add a direct attachment (not via RichText embeds) to Comment, Board, or
|
||||
# another model with Storage::Tracked, you must add its record_type here or the
|
||||
# no-reuse validation won't protect it.
|
||||
TRACKED_RECORD_TYPES = %w[Card ActionText::RichText].freeze
|
||||
|
||||
# Account ID for template/demo blobs that can be reused cross-tenant.
|
||||
# Set to nil to disable the whitelist (no exemptions).
|
||||
TEMPLATE_ACCOUNT_ID = nil
|
||||
end
|
||||
|
||||
@@ -26,24 +26,23 @@ module Storage::AttachmentTracking
|
||||
return unless @storage_snapshot
|
||||
|
||||
Storage::Entry.record \
|
||||
account_id: @storage_snapshot[:account_id],
|
||||
board_id: @storage_snapshot[:board_id],
|
||||
recordable_type: @storage_snapshot[:recordable_type],
|
||||
recordable_id: @storage_snapshot[:recordable_id],
|
||||
blob_id: @storage_snapshot[:blob_id],
|
||||
account: @storage_snapshot[:account],
|
||||
board: @storage_snapshot[:board],
|
||||
recordable: @storage_snapshot[:recordable],
|
||||
blob: blob,
|
||||
delta: -blob.byte_size,
|
||||
operation: "detach"
|
||||
end
|
||||
|
||||
# Snapshot records in before_destroy since parent may be deleted by the time
|
||||
# after_destroy_commit runs. The records may be destroyed but .id still works.
|
||||
def snapshot_storage_context
|
||||
return unless storage_tracked_record
|
||||
|
||||
@storage_snapshot = {
|
||||
account_id: storage_tracked_record.account.id,
|
||||
board_id: storage_tracked_record.board_for_storage_tracking&.id,
|
||||
recordable_type: storage_tracked_record.class.name,
|
||||
recordable_id: storage_tracked_record.id,
|
||||
blob_id: blob.id
|
||||
account: storage_tracked_record.account,
|
||||
board: storage_tracked_record.board_for_storage_tracking,
|
||||
recordable: storage_tracked_record
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user