Merge branch 'main' into closing-soon-badge

This commit is contained in:
Andy Smith
2025-05-01 09:59:42 +02:00
committed by GitHub
319 changed files with 3881 additions and 2581 deletions
+5
View File
@@ -0,0 +1,5 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Production/SECRET_KEY_BASE)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
SECRET_KEY_BASE=$(kamal secrets extract SECRET_KEY_BASE $SECRETS)
+5
View File
@@ -38,6 +38,10 @@ RUN bundle exec bootsnap precompile app/ lib/
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Fetch beamer library
FROM basecamp/beamer:latest AS beamer
# Final stage for app image
FROM base
@@ -49,6 +53,7 @@ RUN apt-get update -qq && \
# Copy built artifacts: gems, application
COPY --from=build /usr/local/bundle /usr/local/bundle
COPY --from=build /rails /rails
COPY --from=beamer /home/beamer/bin/beamer.so /rails/bin/lib/beamer.so
# Run and own only the runtime files as a non-root user for security
RUN useradd rails --create-home --shell /bin/bash && \
+31 -31
View File
@@ -1,6 +1,6 @@
GIT
remote: https://github.com/basecamp/active_record-tenanted
revision: 8758240be33a6f6f7e7f56486a2e78008c193030
revision: 612f5e8b7f77b7076a7eb21521920630161461aa
specs:
active_record-tenanted (0.1.0)
activerecord (>= 8.1.alpha)
@@ -18,7 +18,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 32d96235810e268eb5335a7837b13cda3f8b653d
revision: bc7f77b32d7f3f26db654cae506c26a959852506
branch: main
specs:
actioncable (8.1.0.alpha)
@@ -145,13 +145,13 @@ GEM
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
connection_pool (2.5.3)
crass (1.0.6)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.7)
dotenv (3.1.8)
drb (2.2.1)
ed25519 (1.3.0)
erubi (1.13.1)
@@ -179,7 +179,7 @@ GEM
jbuilder (2.13.0)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
json (2.10.2)
json (2.11.3)
kamal (2.5.3)
activesupport (>= 7.0)
base64 (~> 0.2)
@@ -208,7 +208,7 @@ GEM
mini_portile2 (2.8.8)
minitest (5.25.5)
msgpack (1.8.0)
net-imap (0.5.6)
net-imap (0.5.7)
date
net-protocol
net-pop (0.1.2)
@@ -223,28 +223,28 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.4)
nokogiri (1.18.7)
nokogiri (1.18.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.7-aarch64-linux-gnu)
nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-aarch64-linux-musl)
nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-gnu)
nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-musl)
nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm64-darwin)
nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-darwin)
nokogiri (1.18.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-gnu)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-musl)
nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.1)
parallel (1.26.3)
parser (3.3.7.4)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
racc
pp (0.6.2)
@@ -264,7 +264,7 @@ GEM
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.12)
rack (3.1.13)
rack-contrib (2.5.0)
rack (< 4)
rack-session (2.1.0)
@@ -290,12 +290,12 @@ GEM
reline (0.6.1)
io-console (~> 0.5)
rexml (3.4.1)
rouge (4.5.1)
rqrcode (2.2.0)
rouge (4.5.2)
rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rubocop (1.75.2)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rubocop (1.75.4)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -306,7 +306,7 @@ GEM
rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.0)
rubocop-ast (1.44.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.25.0)
@@ -347,7 +347,7 @@ GEM
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.1.4)
solid_queue (1.1.5)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
@@ -373,13 +373,13 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.6)
stringio (3.1.7)
thor (1.3.2)
thruster (0.1.12)
thruster (0.1.12-aarch64-linux)
thruster (0.1.12-arm64-darwin)
thruster (0.1.12-x86_64-darwin)
thruster (0.1.12-x86_64-linux)
thruster (0.1.13)
thruster (0.1.13-aarch64-linux)
thruster (0.1.13-arm64-darwin)
thruster (0.1.13-x86_64-darwin)
thruster (0.1.13-x86_64-linux)
timeout (0.4.3)
turbo-rails (2.0.13)
actionpack (>= 7.1.0)
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m23.1 12.9h-3v3c0 .5-.4.9-.9.9s-.9-.4-.9-.9v-3h-3c-.5 0-.9-.4-.9-.9s.4-.9.9-.9h3v-3c0-.5.4-.9.9-.9s.9.4.9.9v3s3 0 3 0c.5 0 .9.4.9.9s-.4.9-.9.9zm-7.6 8.2h-15c-.3 0-.5-.2-.5-.5.1-2.1 1.1-2.6 3.2-3.4 2-.7 2.6-1.5 2.8-3.3-.3-.2-.6-.5-.9-.8-.5-.6-.8-1.4-.9-2.1-.5 0-.9-1.1-.9-1.8s.3-1 .5-1 0 0 .1 0c-.1-.5-.2-1-.2-1.4 0-2.6 1.1-4.1 4.3-4.1s1.7.4 2 .9c.2-.1.4-.2.6-.2.8 0 1.8 1 1.8 3.3 0 .5 0 1-.2 1.4h.1c.2 0 .5.2.5 1s-.4 1.8-.9 1.8c0 .8-.4 1.5-.9 2.1-.3.3-.6.5-.9.8.1 1.8.8 2.5 2.8 3.3 2.1.8 3 1.3 3.2 3.4 0 .3-.2.5-.5.5z" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 615 B

+239 -64
View File
@@ -11,8 +11,8 @@
/* Text */
--text-xx-small: 0.55rem;
--text-x-small: 0.7rem;
--text-small: 0.8rem;
--text-x-small: 0.75rem;
--text-small: 0.85rem;
--text-normal: 1rem;
--text-medium: 1.1rem;
--text-large: 1.5rem;
@@ -20,78 +20,253 @@
--text-xx-large: 2.5rem;
/* Borders */
--border: 1px solid var(--color-subtle);
--border: 1px solid var(--color-ink-lighter);
/* Shadows */
--shadow: 0 0 0 1px oklch(var(--lch-always-black) / 0.02),
0 .2em 1.6em -0.8em oklch(var(--lch-always-black) / 0.2),
0 .4em 2.4em -1em oklch(var(--lch-always-black) / 0.3),
0 .4em .8em -1.2em oklch(var(--lch-always-black) / 0.4),
0 .8em 1.2em -1.6em oklch(var(--lch-always-black) / 0.5),
0 1.2em 1.6em -2em oklch(var(--lch-always-black) / 0.6);
--shadow: 0 0 0 1px oklch(var(--lch-black) / 0.02),
0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.2),
0 .4em 2.4em -1em oklch(var(--lch-black) / 0.3),
0 .4em .8em -1.2em oklch(var(--lch-black) / 0.4),
0 .8em 1.2em -1.6em oklch(var(--lch-black) / 0.5),
0 1.2em 1.6em -2em oklch(var(--lch-black) / 0.6);
/* Components */
--btn-size: 2.65em;
/* Color values */
--lch-black: 27.13% 0.1035 284.77;
/* OKLCH colors: Fixed */
--lch-black: 0% 0 0;
--lch-white: 100% 0 0;
--lch-gray-light: 96% 0.005 96;
--lch-gray: 92% 0.005 96;
--lch-gray-dark: 75% 0.005 96;
--lch-blue: 54% 0.15 255;
--lch-blue-light: 95% 0.03 255;
--lch-blue-dark: 80% 0.08 255;
--lch-golden: 83% 0.1128 72.43;
--lch-orange: 70% 0.2 44;
--lch-red: 51% 0.2 31;
--lch-green: 65.59% 0.234 142.49;
--lch-green-light: 95% 0.03 142.49;
--lch-yellow: 92.62% 0.1 91.5;
--lch-always-black: 0% 0 0;
--lch-always-white: 100% 0 0;
--lch-light-blue: 84.94% 0.048 216.62;
/* Color abstractions */
--color-negative: oklch(var(--lch-red));
--color-positive: oklch(var(--lch-green));
--color-positive-light: oklch(var(--lch-green-light));
--color-bg: oklch(var(--lch-white));
--color-ink: oklch(var(--lch-black));
--color-ink-reversed: oklch(var(--lch-white));
--color-link: oklch(var(--lch-blue));
--color-subtle-light: oklch(var(--lch-gray-light));
--color-subtle: oklch(var(--lch-gray));
--color-subtle-dark: oklch(var(--lch-gray-dark));
--color-selected: oklch(var(--lch-blue-light));
--color-selected-dark: oklch(var(--lch-blue-dark));
--color-highlight: oklch(var(--lch-yellow));
--color-marker: oklch(var(--lch-orange));
--color-always-black: oklch(var(--lch-always-black));
--color-always-white: oklch(var(--lch-always-white));
--color-container: oklch(var(--lch-light-blue));
--color-golden: oklch(var(--lch-golden));
/* OKLCH colors: Light mode */
--lch-canvas: var(--lch-white);
--lch-ink-inverted: var(--lch-white);
--lch-ink-darkest: 26% 0.05 264;
--lch-ink-darker: 40% 0.026 262;
--lch-ink-dark: 57% 0.014 260;
--lch-ink-medium: 70% 0.008 258;
--lch-ink-light: 84% 0.005 256;
--lch-ink-lighter: 92% 0.003 254;
--lch-ink-lightest: 96% 0.002 252;
--lch-uncolor-darkest: 26% 0.018 40;
--lch-uncolor-darker: 40% 0.03 50;
--lch-uncolor-dark: 57% 0.04 60;
--lch-uncolor-medium: 70% 0.044 70;
--lch-uncolor-light: 84% 0.028 80;
--lch-uncolor-lighter: 92% 0.014 90;
--lch-uncolor-lightest: 96% 0.012 100;
--lch-red-darkest: 26% 0.105 34;
--lch-red-darker: 40% 0.154 36;
--lch-red-dark: 59% 0.19 38;
--lch-red-medium: 70.4% 0.19 40;
--lch-red-light: 84% 0.07 42;
--lch-red-lighter: 92% 0.03 44;
--lch-red-lightest: 96% 0.013 46;
--lch-yellow-darkest: 26% 0.0729 40;
--lch-yellow-darker: 40% 0.12 50;
--lch-yellow-dark: 58% 0.156 60;
--lch-yellow-medium: 76% 0.17 70;
--lch-yellow-light: 84% 0.12 80;
--lch-yellow-lighter: 92% 0.076 90;
--lch-yellow-lightest: 96% 0.034 100;
--lch-lime-darkest: 26% 0.064 109;
--lch-lime-darker: 40% 0.101 110;
--lch-lime-dark: 56.5% 0.142 111;
--lch-lime-medium: 71% 0.158 112;
--lch-lime-light: 84% 0.08 113;
--lch-lime-lighter: 92% 0.046 114;
--lch-lime-lightest: 96% 0.034 115;
--lch-green-darkest: 26% 0.071 149;
--lch-green-darker: 40% 0.12 148;
--lch-green-dark: 55% 0.162 147;
--lch-green-medium: 70% 0.19 146;
--lch-green-light: 84% 0.07 145;
--lch-green-lighter: 92% 0.044 144;
--lch-green-lightest: 96% 0.022 143;
--lch-aqua-darkest: 26% 0.059 214;
--lch-aqua-darker: 40% 0.093 212;
--lch-aqua-dark: 55.5% 0.122 210;
--lch-aqua-medium: 70% 0.13 208;
--lch-aqua-light: 84% 0.04 206;
--lch-aqua-lighter: 92% 0.02 204;
--lch-aqua-lightest: 96% 0.012 202;
--lch-blue-darkest: 26% 0.126 264;
--lch-blue-darker: 40% 0.166 262;
--lch-blue-dark: 57% 0.19 260;
--lch-blue-medium: 70% 0.169 258;
--lch-blue-light: 84% 0.06 256;
--lch-blue-lighter: 92% 0.026 254;
--lch-blue-lightest: 96% 0.016 252;
--lch-violet-darkest: 26% 0.148 292;
--lch-violet-darker: 40% 0.2 290;
--lch-violet-dark: 58% 0.216 288;
--lch-violet-medium: 70% 0.178 286;
--lch-violet-light: 84% 0.07 284;
--lch-violet-lighter: 92% 0.03 282;
--lch-violet-lightest: 96% 0.015 280;
--lch-purple-darkest: 26% 0.131 314;
--lch-purple-darker: 40% 0.178 312;
--lch-purple-dark: 58% 0.21 310;
--lch-purple-medium: 70% 0.22 308;
--lch-purple-light: 84% 0.07 306;
--lch-purple-lighter: 92% 0.03 304;
--lch-purple-lightest: 96% 0.019 302;
--lch-pink-darkest: 26% 0.12 348;
--lch-pink-darker: 40% 0.16 346;
--lch-pink-dark: 59% 0.188 344;
--lch-pink-medium: 70% 0.198 342;
--lch-pink-light: 84% 0.07 340;
--lch-pink-lighter: 92% 0.03 338;
--lch-pink-lightest: 96% 0.02 336;
/* Colors: Named */
--color-black: oklch(var(--lch-black));
--color-white: oklch(var(--lch-white));
--color-ink: oklch(var(--lch-ink-darkest));
--color-ink-darkest: oklch(var(--lch-ink-darkest));
--color-ink-darker: oklch(var(--lch-ink-darker));
--color-ink-dark: oklch(var(--lch-ink-dark));
--color-ink-medium: oklch(var(--lch-ink-medium));
--color-ink-light: oklch(var(--lch-ink-light));
--color-ink-lighter: oklch(var(--lch-ink-lighter));
--color-ink-lightest: oklch(var(--lch-ink-lightest));
--color-ink-inverted: oklch(var(--lch-ink-inverted));
/* Colors: Abstractions */
--color-canvas: oklch(var(--lch-canvas));
--color-negative: oklch(var(--lch-red-dark));
--color-positive: oklch(var(--lch-green-dark));
--color-link: oklch(var(--lch-blue-dark));
--color-selected: oklch(var(--lch-blue-lighter));
--color-selected-dark: oklch(var(--lch-blue-light));
--color-highlight: oklch(var(--lch-yellow-lighter));
--color-marker: oklch(var(--lch-red-medium));
--color-container: oklch(var(--lch-aqua-light));
--color-terminal-bg: oklch(var(--lch-blue-darkest));
--color-terminal-text: oklch(var(--lch-green-medium));
/* Colors: Cards */
--color-card-1: oklch(var(--lch-ink-dark));
--color-card-2: oklch(var(--lch-uncolor-dark));
--color-card-3: oklch(var(--lch-yellow-dark));
--color-card-4: oklch(var(--lch-lime-dark));
--color-card-5: oklch(var(--lch-aqua-dark));
--color-card-6: oklch(var(--lch-blue-dark));
--color-card-7: oklch(var(--lch-violet-dark));
--color-card-8: oklch(var(--lch-purple-dark));
--color-card-9: oklch(var(--lch-pink-dark));
@media (prefers-color-scheme: dark) {
--lch-black: 100% 0 0;
--lch-white: 0% 0 0;
--lch-gray-light: 25.2% 0 0;
--lch-gray: 30.12% 0 0;
--lch-gray-dark: 44.95% 0 0;
--lch-blue: 72.25% 0.16 248;
--lch-blue-light: 28.11% 0.053 248;
--lch-blue-dark: 42.25% 0.07 248;
--lch-red: 73.8% 0.184 29.18;
--lch-green: 75% 0.21 141.89;
--lch-green-light: 28.11% 0.02 142.49;
--lch-yellow: 40.9% 0.06 88.9;
--lch-light-blue: 20.62% 0.048 216.62;
--lch-canvas: 20% 0.0195 232.58;
--lch-ink-inverted: var(--lch-black);
--shadow: 0 0 0 1px oklch(var(--lch-always-black) / 0.42),
0 .2em 1.6em -0.8em oklch(var(--lch-always-black) / 0.6),
0 .4em 2.4em -1em oklch(var(--lch-always-black) / 0.7),
0 .4em .8em -1.2em oklch(var(--lch-always-black) / 0.8),
0 .8em 1.2em -1.6em oklch(var(--lch-always-black) / 0.9),
0 1.2em 1.6em -2em oklch(var(--lch-always-black) / 1);
--lch-ink-darkest: 96.02% 0.0034 260;
--lch-ink-darker: 86% 0.0061 260;
--lch-ink-dark: 73.97% 0.009 260;
--lch-ink-medium: 62% 0.0122 260;
--lch-ink-light: 40% 0.0148 260;
--lch-ink-lighter: 30% 0.0178 260;
--lch-ink-lightest: 25% 0.0204 260;
--lch-uncolor-darkest: 96.09% 0.0076 100;
--lch-uncolor-darker: 86% 0.021 90;
--lch-uncolor-dark: 73.93% 0.041 80;
--lch-uncolor-medium: 62% 0.0552 70;
--lch-uncolor-light: 40% 0.0387 60;
--lch-uncolor-lighter: 30% 0.012 50;
--lch-uncolor-lightest: 25% 0.0017 40;
--lch-red-darkest: 95.85% 0.0218 46;
--lch-red-darker: 86% 0.086 44;
--lch-red-dark: 73.95% 0.139 42;
--lch-red-medium: 62% 0.154 40;
--lch-red-light: 40% 0.088 38;
--lch-red-lighter: 30% 0.032 36;
--lch-red-lightest: 25% 0.011 34;
--lch-yellow-darkest: 96% 0.056 100;
--lch-yellow-darker: 86% 0.103 90;
--lch-yellow-dark: 74.06% 0.136 80;
--lch-yellow-medium: 62.1% 0.146 70;
--lch-yellow-light: 40% 0.0736 60;
--lch-yellow-lighter: 30% 0.026 50;
--lch-yellow-lightest: 25% 0.01 40;
--lch-lime-darkest: 96.04% 0.066 115;
--lch-lime-darker: 86% 0.098 114;
--lch-lime-dark: 73.97% 0.121 113;
--lch-lime-medium: 62% 0.128 112;
--lch-lime-light: 40% 0.0637 111;
--lch-lime-lighter: 30% 0.024 110;
--lch-lime-lightest: 25% 0.012 109;
--lch-green-darkest: 96.12% 0.035 143;
--lch-green-darker: 86% 0.082 144;
--lch-green-dark: 73.99% 0.117 145;
--lch-green-medium: 62% 0.1261 146;
--lch-green-light: 40% 0.065 147;
--lch-green-lighter: 30% 0.03 148;
--lch-green-lightest: 25% 0.018 149;
--lch-aqua-darkest: 96.15% 0.0244 202;
--lch-aqua-darker: 86% 0.06 204;
--lch-aqua-dark: 73.92% 0.095 206;
--lch-aqua-medium: 62% 0.106 208;
--lch-aqua-light: 40% 0.0594 210;
--lch-aqua-lighter: 30% 0.028 212;
--lch-aqua-lightest: 25% 0.017 214;
--lch-blue-darkest: 95.93% 0.0217 252;
--lch-blue-darker: 86% 0.068 254;
--lch-blue-dark: 74% 0.1293 256;
--lch-blue-medium: 62% 0.159 258;
--lch-blue-light: 40% 0.094 260;
--lch-blue-lighter: 30% 0.0452 262;
--lch-blue-lightest: 25% 0.0318 264;
--lch-violet-darkest: 95.97% 0.019 280;
--lch-violet-darker: 86% 0.068 282;
--lch-violet-dark: 74.08% 0.142 284;
--lch-violet-medium: 62% 0.184 286;
--lch-violet-light: 40% 0.108 288;
--lch-violet-lighter: 30% 0.048 290;
--lch-violet-lightest: 25% 0.025 292;
--lch-purple-darkest: 95.99% 0.0217 302;
--lch-purple-darker: 86% 0.068 304;
--lch-purple-dark: 73.98% 0.141 306;
--lch-purple-medium: 62% 0.177 308;
--lch-purple-light: 40% 0.099 310;
--lch-purple-lighter: 30% 0.04 312;
--lch-purple-lightest: 25% 0.017 314;
--lch-pink-darkest: 95.84% 0.0308 336;
--lch-pink-darker: 86% 0.074 338;
--lch-pink-dark: 74.04% 0.1294 340;
--lch-pink-medium: 62% 0.166 342;
--lch-pink-light: 40% 0.085 344;
--lch-pink-lighter: 30% 0.03 346;
--lch-pink-lightest: 25% 0.011 348;
--color-container: oklch(var(--lch-aqua-lighter));
--shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42),
0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.6),
0 .4em 2.4em -1em oklch(var(--lch-black) / 0.7),
0 .4em .8em -1.2em oklch(var(--lch-black) / 0.8),
0 .8em 1.2em -1.6em oklch(var(--lch-black) / 0.9),
0 1.2em 1.6em -2em oklch(var(--lch-black) / 1);
}
}
+2 -1
View File
@@ -10,7 +10,8 @@
margin: 0;
place-items: center;
img {
img,
.icon {
aspect-ratio: 1;
block-size: auto;
border-radius: var(--avatar-border-radius);
+15 -2
View File
@@ -11,14 +11,14 @@
--font-sans: system-ui;
--font-serif: ui-serif, serif;
--font-mono: ui-monospace, monospace;
--hover-color: var(--color-subtle-dark);
--hover-color: var(--color-ink-medium);
--hover-size: 0.15rem;
--hover-filter: brightness(1);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: none;
background: var(--color-bg);
background: var(--color-canvas);
color: var(--color-ink);
font-family: var(--font-sans);
interpolate-size: allow-keywords;
@@ -85,6 +85,19 @@
list-style: none;
}
kbd {
border: 1px solid;
border-radius: .3em;
box-shadow: 0 0.1em 0 currentColor;
font-family: var(--font-mono);
font-size: var(--text-x-small);
font-weight: 600;
opacity: 0.7;
padding: 0em 0.4em;
text-transform: uppercase;
white-space: nowrap;
}
video {
max-inline-size: 100%;
}
+8 -8
View File
@@ -4,9 +4,9 @@
--btn-border-radius: 2em;
align-items: center;
background-color: var(--btn-background, var(--color-bg));
background-color: var(--btn-background, var(--color-canvas));
border-radius: var(--btn-border-radius);
border: var(--btn-border-size, 1px) solid var(--btn-border-color, var(--color-subtle-dark));
border: var(--btn-border-size, 1px) solid var(--btn-border-color, var(--color-ink-light));
color: var(--btn-color, var(--color-ink));
cursor: pointer;
display: inline-flex;
@@ -112,7 +112,7 @@
&:has(input:checked) {
--btn-background: var(--color-ink);
--btn-color: var(--color-ink-reversed);
--btn-color: var(--color-ink-inverted);
--outline-color: var(--color-ink);
.checked {
@@ -137,13 +137,13 @@
/* Variants */
.btn--link {
--btn-background: var(--color-link);
--btn-color: var(--color-ink-reversed);
--btn-color: var(--color-ink-inverted);
--outline-color: var(--color-link);
}
.btn--negative {
--btn-background: var(--color-negative);
--btn-color: var(--color-ink-reversed);
--btn-color: var(--color-ink-inverted);
--outline-color: var(--color-negative);
}
@@ -163,7 +163,7 @@
.btn--positive {
--btn-background: var(--color-positive);
--btn-color: var(--color-ink-reversed);
--btn-color: var(--color-ink-inverted);
--outline-color: var(--color-positive);
}
@@ -173,12 +173,12 @@
.btn--reversed {
--btn-background: var(--color-ink);
--btn-color: var(--color-bg);
--btn-color: var(--color-canvas);
--outline-color: var(--color-ink);
}
:is(.btn--link, .btn--negative, .btn--positive, .btn--reversed) {
--btn-border-color: var(--color-bg);
--btn-border-color: var(--color-canvas);
}
.btn--success {
+6 -9
View File
@@ -10,7 +10,7 @@
}
.card-columns {
border-block-start: 1px solid var(--color-subtle);
border-block-start: 1px solid var(--color-ink-lighter);
display: grid;
grid-template-columns: repeat(2, 50%);
margin: auto;
@@ -77,14 +77,10 @@
--text-xx-large: 1.6em;
--text-small: 1.1em;
background-color: var(--color-bg);
box-shadow: 0 0 0 1px var(--color-subtle);
background-color: var(--color-canvas);
box-shadow: 0 0 0 1px var(--color-ink-lighter);
line-height: 1.2;
@media (prefers-color-scheme: dark) {
box-shadow: 0 0 0 1px var(--color-subtle-dark);
}
.avatar {
block-size: 2.75em;
inline-size: 2.75em;
@@ -92,10 +88,11 @@
}
.card--drafted {
--card-color: var(--color-subtle-dark) !important;
--card-color: var(--color-ink-medium) !important;
border-color: var(--card-color) !important;
border-style: dashed !important;
box-shadow: none !important;
}
.card__background {
@@ -124,7 +121,7 @@
/* -------------------------------------------------------------------------- */
.cards--closed {
border-block-start: 1px solid var(--color-subtle);
border-block-start: 1px solid var(--color-ink-lighter);
justify-content: center;
flex-direction: row;
flex-wrap: wrap;
+21 -38
View File
@@ -4,9 +4,9 @@
.card-perma {
--actions-block-inset: 1.5rem;
--actions-inline-inset: 4rem;
--color-container: color-mix(in srgb, var(--card-color) 33%, var(--color-bg));
--color-container: color-mix(in srgb, var(--card-color) 33%, var(--color-canvas));
--padding-inline: calc(var(--block-space-double) + var(--block-space));
--padding-block: var(--block-space-double) calc(var(--block-space-double) + var(--block-space-half));
--padding-block: calc(var(--block-space-double) + var(--block-space-half));
align-items: start;
column-gap: var(--inline-space);
@@ -15,7 +15,7 @@
"qw notch-top we"
"actions-left card actions-right"
"er notch-bottom rt";
grid-template-columns: 48px minmax(0, 960px) 48px;
grid-template-columns: 48px minmax(0, 1120px) 48px;
inline-size: fit-content;
max-inline-size: 100%;
margin-inline: auto;
@@ -31,6 +31,15 @@
border: none;
}
.card__meta,
.card__stages {
font-size: var(--text-small);
}
.card__stages {
max-inline-size: 32ch;
}
}
/* Children
@@ -60,12 +69,7 @@
/* -------------------------------------------------------------------------- */
.card-perma__notch {
--card-notch-padding: 0.5ch;
--border-radius: 1em;
align-items: center;
background-color: var(--color-bg);
border-radius: 99rem;
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
display: inline-flex;
inline-size: auto;
@@ -92,23 +96,22 @@
&:has(input:checked) {
--btn-background: var(--card-color);
--btn-color: var(--color-ink-reversed);
--btn-color: var(--color-ink-inverted);
}
}
}
.card-perma__notch--bottom {
grid-area: notch-bottom;
padding: calc(var(--block-space) * 0.75) calc(var(--block-space) * 0.66);
translate: 0 -50%;
translate: 0 -25%;
&:has([open]) {
z-index: 1;
}
.btn:not(.popup__item, .btn--plain) {
.btn:not(.popup__item, .btn--plain, .btn--reversed) {
--btn-background: var(--card-color);
--btn-color: var(--color-ink-reversed);
--btn-color: var(--color-ink-inverted);
}
.btn--plain {
@@ -116,6 +119,11 @@
text-decoration: underline;
}
.btn--reversed {
--btn-background: var(--color-canvas);
--btn-color: var(--card-color);
}
}
.card-perma__closure-message {
@@ -125,29 +133,4 @@
padding-left: 1ch;
}
}
/* Variants
/* ------------------------------------------------------------------------ */
.card-perma--pointing {
--arrow-size: 20em;
--aspect-ratio: 7;
position: relative;
margin-block-end: calc(var(--arrow-size) / var(--aspect-ratio));
&::after {
aspect-ratio: var(--aspect-ratio);
background-color: var(--color-container);
clip-path: polygon(50% 100%, 100% 0, 0 0);
inline-size: var(--arrow-size);
content: "";
display: block;
inset-block-start: 99%;
inset-inline-start: 50%;
position: absolute;
transform: translateX(-50%);
z-index: -1;
}
}
}
+80 -25
View File
@@ -7,7 +7,7 @@
--border-size: 0;
aspect-ratio: var(--card-aspect-ratio, auto);
background-color: color-mix(in srgb, var(--card-color) 4%, var(--color-bg));
background-color: color-mix(in srgb, var(--card-color) 4%, var(--color-canvas));
border-radius: 0.5em;
box-shadow: var(--shadow);
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
@@ -22,21 +22,27 @@
.avatar {
--avatar-size: 2.75em;
}
@media (prefers-color-scheme: dark) {
--border-color: var(--color-subtle-dark);
}
}
.card--golden.card--doing {
--border-color: var(--color-bg) !important;
--border-color: var(--color-canvas) !important;
--lch-golden: var(--lch-yellow-light);
background-color: var(--color-bg);
background-image: linear-gradient(90deg, oklch(var(--lch-golden) / 0.5) 0%, oklch(var(--lch-golden) / 0.1) 50%, oklch(var(--lch-golden) / 0.75) 100%);
background-color: var(--color-canvas);
background-image: linear-gradient(60deg,
oklch(var(--lch-golden) / 0.35) 0%,
oklch(var(--lch-golden) / 0.15) 20%,
oklch(var(--lch-golden) / 0) 60%,
oklch(var(--lch-golden) / 0.5) 100%
);
box-shadow:
0 0 0.5em 0.2em var(--color-golden),
0 0 1em 0.4em var(--color-golden) !important;
outline: 1px solid var(--color-golden);
0 0 0.5em 0.2em oklch(var(--lch-golden) / 0.35),
0 0 1em 0.4em oklch(var(--lch-golden) / 0.35) !important;
outline: 1px solid oklch(var(--lch-golden) / 0.35);
@media (prefers-color-scheme: dark) {
--lch-golden: var(--lch-blue-medium);
}
}
.card__background {
@@ -99,18 +105,45 @@
padding-block: var(--card-padding-block);
}
.card__description {
house-md-toolbar,
.house-md-content {
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
}
.house-md-content {
min-block-size: 3lh;
padding: var(--block-space-half) var(--inline-space) 0 0;
}
& ~ .btn {
--btn-background: var(--card-color);
--btn-color: var(--color-ink-inverted);
}
}
.card__content {
flex: 2 1 auto;
}
.card__title {
--hover-size: 0;
--input-border-radius: 0;
--input-color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
--lines: 3;
flex: 2 1 75%;
color: inherit;
font-size: var(--text-xx-large);
font-weight: 900;
line-height: 1.1;
line-height: 1.2;
min-block-size: 3lh;
text-wrap: balance;
.card__title-field {
.card-perma__bg & {
min-block-size: unset;
}
.card-field__title {
&:is(textarea)::placeholder {
color: inherit;
opacity: 0.5;
@@ -118,23 +151,34 @@
}
.card__title-link {
--lines: 3;
color: inherit;
}
}
.card__stages {
display: flex;
flex: 1 1 25%;
flex: 0 1 auto;
flex-direction: column;
max-width: 100%;
min-width: 0;
padding-block: var(--block-space);
justify-self: end;
max-inline-size: 20ch;
padding-block: var(--block-space-half);
}
.card--new-card-button:is(.btn) {
--btn-padding: var(--block-space) var(--inline-space-double);
--btn-border-size: 0;
--btn-color: var(--color-link);
--btn-icon-size: 1em;
--card-padding-block: calc(var(--block-space) * 1.25);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-link) 20%, var(--color-canvas));
color: var(--btn-color);
font-weight: 700;
}
.card__move-button {
--btn-background: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
--btn-background: color-mix(in srgb, inherit 40%, var(--color-ink));
margin-block-start: calc(var(--block-space) * 1.2);
margin-inline-end: calc(var(--inline-space) * -4);
@@ -186,16 +230,25 @@
.card__closed {
align-items: center;
aspect-ratio: 7/4;
aspect-ratio: 8/5;
background-color: var(--color-canvas);
border: var(--block-space-half) solid var(--card-color);
border-radius: 0.2em;
color: var(--card-color);
display: flex;
flex-direction: column;
inset: auto var(--block-space) var(--block-space-double) auto;
justify-content: space-between;
max-inline-size: 25ch;
min-inline-size: 18ch;
mix-blend-mode: multiply;
padding: var(--block-space-half) calc(var(--inline-space) * 1.5);
position: absolute;
transform: rotate(-10deg);
z-index: -1;
@media (prefers-color-scheme: dark) {
mix-blend-mode: screen;
}
}
.card__closed-by {
@@ -203,9 +256,11 @@
}
.card__closed-title {
color: var(--color-ink-reversed);
font-size: var(--text-rel-large);
color: var(--color-ink-inverted);
font-size: 1.5em;
font-weight: 900;
line-height: 1.2;
text-align: center;
text-shadow:
-2px -2px 0 var(--card-color),
2px -2px 0 var(--card-color),
+3 -20
View File
@@ -2,34 +2,17 @@
--panel-border-color: var(--card-color);
--panel-border-radius: 2em;
--panel-border-size: 2px;
--panel-padding: 0.5em 0.7em;
--panel-padding: 0.5em;
--panel-size: auto;
inline-size: auto !important;
inset: 0 auto auto 0;
inset: 50% auto auto calc(-1 * var(--panel-padding));
max-inline-size: var(--panel-size) !important;
position: absolute;
translate: 0 -50%;
z-index: 2;
&[open] {
display: flex;
}
.color-picker__color {
--btn-size: 2em;
}
}
.color-picker__button {
font-size: 0.75em;
.cards & {
display: none;
}
+ .panel {
--panel-size: auto;
inset: 0 0 auto auto;
}
}
+3 -2
View File
@@ -12,7 +12,7 @@
text-align: center;
@media (min-width: 160ch) {
padding-inline: calc(var(--tray-size) + var(--inline-space) * 1.5);
padding-inline: var(--tray-size);
}
}
@@ -63,11 +63,12 @@
}
.comment__content {
background-color: var(--color-subtle-light);
background-color: var(--color-ink-lightest);
border-radius: 0.2em;
padding:
var(--comment-padding-block)
var(--comment-padding-inline);
word-wrap: break-word;
}
.comment__edit {
+1 -1
View File
@@ -15,7 +15,7 @@
transform var(--speed);
&::backdrop {
background-color: var(--color-always-black);
background-color: var(--color-black);
opacity: 0;
transform: scale(1);
transition:
+11 -15
View File
@@ -11,13 +11,12 @@
--column-gap: clamp(var(--inline-space), 3cqmin, calc(var(--inline-space) * 3));
--grid-lines: 0.1rem;
display: grid;
grid-template-columns: 1fr 5fr 1fr;
margin: auto;
max-inline-size: min(105ch, 100%);
}
.events__day-header {
background-color: var(--color-bg);
background-color: var(--color-canvas);
block-size: var(--grid-lines);
display: block;
position: relative;
@@ -25,7 +24,7 @@
}
.events__day-header-content {
background-color: var(--color-bg);
background-color: var(--color-canvas);
display: inline-flex;
font-size: 0.8em;
inset: calc(var(--grid-lines) - 1em) auto -1em;
@@ -55,22 +54,13 @@
.events--none {
background-color: var(--color-container);
border-block-start: var(--grid-lines) solid var(--color-bg);
border-block-start: var(--grid-lines) solid var(--color-canvas);
padding-block: 3em;
margin: auto var(--grid-lines) calc(var(--grid-lines) * -1) auto;
}
.events__popup {
.panel:is(&) {
max-block-size: calc(100dvh - (2 * var(--block-space-double)));
max-inline-size: calc(100dvw - (2 * var(--block-space-double)));
}
#header:has(&) {
max-inline-size: 100%;
position: relative;
z-index: 5;
}
margin-block-start: var(--block-space-double);
}
.event {
@@ -93,6 +83,12 @@
z-index: 2;
}
.event__collection {
font-size: var(--text-x-small);
font-weight: 500;
opacity: 0.66;
}
.event__grid-item {
background-color: var(--color-container);
block-size: 100%;
+1 -1
View File
@@ -107,6 +107,6 @@
&:hover,
&:focus-visible {
background-color: oklch(var(--lch-white) / 15%);
background-color: oklch(var(--lch-canvas) / 15%);
}
}
+9 -1
View File
@@ -67,7 +67,7 @@
block-size: 100%;
border-block: 0;
border-inline-end: 0;
border-inline-start: 1px solid var(--color-subtle);
border-inline-start: 1px solid var(--color-ink-lighter);
content: "";
display: inline-flex;
inline-size: 0;
@@ -107,3 +107,11 @@
}
}
}
.filter--active li {
display: none;
&.selected {
display: flex;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@
animation: appear-then-fade 3s 300ms both;
background-color: var(--flash-background, var(--color-ink));
border-radius: 4em;
color: var(--flash-color, var(--color-ink-reversed));
color: var(--flash-color, var(--color-ink-inverted));
display: inline-flex;
font-size: var(--font-size-medium);
margin: 0 auto;
+11 -10
View File
@@ -1,6 +1,8 @@
@layer components {
/* Editor */
house-md {
--placeholder: attr(placeholder);
display: flex;
flex-direction: column;
flex-grow: 1;
@@ -13,7 +15,7 @@
/* Toolbar */
house-md-toolbar {
background-color: inherit;
border-block-end: 1px solid var(--color-subtle-dark);
border-block-end: 1px solid var(--color-ink-medium);
border-radius: 0;
color: currentColor;
display: inline-flex;
@@ -47,7 +49,7 @@
}
&:is(:focus, :hover) {
background-color: var(--color-subtle);
background-color: var(--color-ink-lighter);
}
&:is(:active) {
@@ -58,7 +60,6 @@
/* Markdown Content */
.house-md-content {
caret-color: var(--color-link);
flex-grow: 1;
font-family: var(--font-sans);
text-align: left;
@@ -66,8 +67,8 @@
padding: 1rem;
&.house-md-content-empty::before {
content: attr(placeholder);
color: var(--color-ink);
content: var(--placeholder);
color: currentColor;
opacity: 0.5;
pointer-events: none;
display: block;
@@ -93,7 +94,7 @@
&[status="failed"] {
background-color: var(--color-negative);
color: var(--color-ink-reversed);
color: var(--color-ink-inverted);
font-weight: bold;
padding: 0.5em;
}
@@ -112,13 +113,13 @@
cursor: pointer;
house-md-upload[status="failed"] & {
color: var(--color-ink-reversed);
color: var(--color-ink-inverted);
display: inline-block;
}
&::before {
content: '×';
color: var(--color-ink-reversed);
color: var(--color-ink-inverted);
font-size: 24px;
font-weight: bold;
position: absolute;
@@ -139,7 +140,7 @@
.md-progress-bar {
-webkit-appearance: none;
appearance: none;
background-color: var(--color-subtle);
background-color: var(--color-ink-lighter);
border-radius: 0.5em;
block-size: 1ex;
display: block;
@@ -154,7 +155,7 @@
&::-webkit-progress-bar {
border-radius: 0.5em;
background-color: var(--color-subtle);
background-color: var(--color-ink-lighter);
}
&::-webkit-progress-inner-element {
+1
View File
@@ -50,6 +50,7 @@
.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 "); }
+10 -5
View File
@@ -4,7 +4,7 @@
accent-color: var(--input-accent-color, var(--color-ink));
background-color: var(--input-background, transparent);
border-radius: var(--input-border-radius, 0.5em);
border: var(--input-border-size, 1px) solid var(--input-border-color, var(--color-subtle-dark));
border: var(--input-border-size, 1px) solid var(--input-border-color, var(--color-ink-medium));
color: var(--input-color, var(--color-ink));
font-size: max(16px, 1em);
inline-size: 100%;
@@ -94,6 +94,11 @@
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
}
option {
background-color: var(--color-canvas);
color: var(--color-ink);
}
}
.input--textarea {
@@ -135,7 +140,7 @@
&:has(:focus-visible) {
.switch__btn {
box-shadow:
0 0 0 var(--outline-size) var(--color-bg),
0 0 0 var(--outline-size) var(--color-canvas),
0 0 0 calc(var(--outline-size) * 2) var(--color-ink);
}
}
@@ -148,7 +153,7 @@
}
.switch__btn {
background-color: var(--color-subtle-dark);
background-color: var(--color-ink-medium);
border-radius: 2em;
cursor: pointer;
inset: 0;
@@ -156,7 +161,7 @@
transition: 150ms ease;
&::before {
background-color: var(--color-ink-reversed);
background-color: var(--color-ink-inverted);
block-size: 1.35em;
border-radius: 50%;
content: "";
@@ -168,7 +173,7 @@
}
.switch__input:disabled + & {
background-color: var(--color-subtle-dark) !important;
background-color: var(--color-ink-medium) !important;
cursor: not-allowed;
}
+2 -2
View File
@@ -53,7 +53,7 @@
}
a:not([class]) {
background-color: var(--color-subtle-light);
background-color: var(--color-ink-lightest);
border-radius: 2em;
color: var(--color-ink);
font-weight: 500;
@@ -62,7 +62,7 @@
@media (hover: hover) {
&:hover {
background-color: var(--color-subtle);
background-color: var(--color-ink-lighter);
text-decoration: none;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
.lightbox {
--backdrop-speed: 150ms;
background-color: oklch(var(--lch-white) / 0.66);
background-color: oklch(var(--lch-canvas) / 0.66);
block-size: 100dvh;
border: 0;
inline-size: 100dvw;
@@ -1,5 +1,5 @@
@layer components {
.comments {
.markdown-content {
:where(h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6) {
font-weight: 800;
hyphens: auto;
@@ -27,7 +27,7 @@
}
:where(hr) {
border-color: var(--color-subtle-dark);
border-color: var(--color-ink-medium);
border-style: var(--border-style, solid) none none;
margin: 2lh auto;
}
@@ -37,8 +37,8 @@
}
:is(pre, code, .pre, .code) {
background-color: var(--color-subtle-light);
border: 1px solid var(--color-subtle);
background-color: var(--color-ink-lightest);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.3em;
font-family: var(--font-mono);
font-size: 0.85em;
@@ -80,7 +80,7 @@
}
:where(table, th, td) {
border: 1px solid var(--color-subtle-dark);
border: 1px solid var(--color-ink-medium);
}
:where(table) {
+19 -5
View File
@@ -46,11 +46,11 @@
padding: var(--padding);
@media (prefers-color-scheme: dark) {
--border-color: var(--color-subtle-dark);
--border-color: var(--color-ink-medium);
}
.notificiations-list--read & {
--border-color: var(--color-subtle);
--border-color: var(--color-ink-lighter);
box-shadow: none !important;
}
@@ -62,7 +62,7 @@
.notification__unread_indicator {
--btn-background: var(--color-marker);
--btn-border-color: var(--color-bg);
--btn-border-color: var(--color-canvas);
--btn-icon-size: 0.7em;
--btn-padding: 0;
--btn-size: 1.2em;
@@ -78,8 +78,8 @@
@media (hover: hover) {
&:hover {
--btn-background: var(--color-subtle-dark);
--btn-border-color: var(--color-subtle-dark);
--btn-background: var(--color-ink-medium);
--btn-border-color: var(--color-ink-medium);
scale: 1;
@@ -94,3 +94,17 @@
}
}
}
.notifications__empty-message {
.notificiations-list:has(.notification) & {
display: none;
}
}
.notificiations-list--read {
display: none;
&:has(.notification) {
display: flex;
}
}
+16 -4
View File
@@ -1,15 +1,27 @@
@layer components {
.panel {
background-color: var(--color-bg);
border: var(--panel-border-size, 1px) solid var(--panel-border-color, var(--color-subtle));
background-color: var(--color-canvas);
border: var(--panel-border-size, 1px) solid var(--panel-border-color, var(--color-ink-lighter));
border-radius: var(--panel-border-radius, 1em);
color: var(--color-ink);
inline-size: var(--panel-size, 60ch);
max-inline-size: 100%;
padding: var(--panel-padding, calc(var(--block-space) * 2));
}
@media (prefers-color-scheme: dark) {
--panel-border-color: var(--color-subtle-dark);
.panels--two-up {
--panel-size: auto;
display: flex;
flex-wrap: wrap;
gap: var(--block-space-double);
align-content: start;
max-inline-size: min(100ch, 100%);
margin: auto;
.panel {
flex: 1 1 33%;
min-inline-size: 36ch;
}
}
}
+8 -5
View File
@@ -30,7 +30,8 @@
--block-space-half: 0.5em;
--text-xx-large: 2em;
border: 1px solid var(--color-bg);
border: 1px solid var(--color-canvas);
view-transition-name: unset !important;
}
.card__collection {
@@ -42,16 +43,18 @@
padding-block-end: 0;
}
.card__link {
z-index: 1;
}
.card__title {
--lines: 1;
font-size: var(--text-small);
font-weight: bold;
min-block-size: 0;
}
.card__title-link {
--lines: 1;
}
.card__stages {
padding: 0;
}
+107 -16
View File
@@ -1,41 +1,75 @@
@layer components {
.popup {
--panel-border-radius: 0.5em;
--panel-padding: 0.4em 0.5em 0.5em;
--panel-padding: var(--block-space);
--panel-size: auto;
inline-size: auto;
inset: 0 auto auto 0;
min-inline-size: 20ch;
max-inline-size: 40ch;
inset: 0 auto auto 50%;
min-inline-size: min(25ch, 90vw);
max-inline-size: min(50ch, 90vw);
position: absolute;
transform: translateX(-50%);
z-index: 2;
&[open] {
display: flex;
}
#header:has(&) {
max-inline-size: 100%;
position: relative;
z-index: 5;
.popup {
min-inline-size: 40ch;
max-inline-size: 40ch;
}
}
.card & {
min-inline-size: 25ch;
max-inline-size: 25ch;
}
}
.popup__group {
background: var(--color-ink-lightest);
max-inline-size: 100%;
padding-inline: var(--inline-space);
.btn {
--btn-background: transparent;
}
@media (any-hover: hover) {
&:hover {
background: var(--color-selected);
}
}
&:has(:focus-visible) {
background: var(--color-selected-dark);
.popup__item {
--outline-size: 0;
}
}
}
.popup__item {
--btn-background: var(--color-subtle-light);
--btn-background: var(--color-ink-lightest);
--btn-border-radius: 0.3em;
--btn-border-size: 0;
--btn-font-weight: 500;
--btn-padding: var(--inline-space-half) var(--inline-space-half) var(--inline-space-half) var(--inline-space);
--btn-padding: var(--inline-space-half) 0 var(--inline-space-half) var(--inline-space);
--hover-size: 0;
--outline-color: transparent;
flex: 1 1 auto;
justify-content: start;
&::after {
border-radius: var(--btn-border-radius);
border: var(--btn-border-size) solid var(--btn-border-color, var(--color-subtle));
content: "";
display: block;
inline-size: calc(100% - var(--btn-padding) * 2);
block-size: calc(100% - var(--btn-padding) * 2);
}
max-inline-size: 100%;
text-align: start;
@media (any-hover: hover) {
&:where(:not(:active):hover) {
@@ -75,10 +109,67 @@
}
.popup__list {
max-block-size: 40dvh;
overflow: auto;
row-gap: 2px;
}
.popup__new {
--btn-icon-size: 1em;
.icon {
margin-inline-end: 0.5em;
}
padding-inline-start: var(--inline-space-half);
}
.popup__title {
font-weight: 800;
}
}
.popup--animated {
--backdrop-speed: 150ms;
--panel-size: max-content;
--speed: 150ms;
opacity: 0;
transform: scale(0.2) translateX(-50%);
transform-origin: top left;
transition:
display var(--speed) allow-discrete,
opacity var(--speed),
overlay var(--speed) allow-discrete,
transform var(--speed);
&::backdrop {
background-color: var(--color-always-black);
opacity: 0;
transform: scale(1);
transition:
display var(--backdrop-speed) allow-discrete,
opacity var(--backdrop-speed),
overlay var(--backdrop-speed) allow-discrete;
}
&[open] {
opacity: 1;
transform: scale(1) translateX(-50%);
&::backdrop {
opacity: 0.5;
}
}
@starting-style {
&[open] {
opacity: 0;
transform: scale(0.2) translateX(-50%);
}
&[open]::backdrop {
opacity: 0;
}
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
@layer components {
.reactions {
--column-gap: 0.4ch;
--reaction-border-color: var(--color-subtle);
--reaction-border-color: var(--color-ink-lighter);
--row-gap: 0;
margin-block-start: calc(var(--block-space-half) / 2);
@@ -30,7 +30,7 @@
@media (any-hover: hover) {
&:where(:not(:active):hover) {
--reaction-border-color: var(--color-subtle-dark);
--reaction-border-color: var(--color-ink-medium);
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
}
@@ -52,7 +52,7 @@
@media (any-hover: hover) {
&:where(:not(:active):hover) {
--reaction-border-color: var(--color-subtle-dark);
--reaction-border-color: var(--color-ink-medium);
}
}
}
+2 -2
View File
@@ -39,7 +39,7 @@
}
.gr {
color: var(--color-subtle-light);
color: var(--color-ink-lightest);
}
.gd {
@@ -83,7 +83,7 @@
}
.err {
color: var(--color-ink-reversed);
color: var(--color-ink-inverted);
background-color: var(--markup-deleted);
}
+3 -2
View File
@@ -22,8 +22,9 @@
}
.tag-picker__button {
font-size: 0.7em;
margin-inline-start: 0.5em;
font-size: 0.6em;
margin-block-start: 0.1em;
margin-inline-start: 0.2em;
+ .panel {
--panel-size: 18ch;
+10 -10
View File
@@ -2,8 +2,8 @@
.terminal {
--row-gap: 0.2lh;
background-color: var(--color-ink);
color: var(--color-positive);
background-color: var(--color-terminal-bg);
color: var(--color-terminal-text);
font-family: var(--font-mono);
font-size: var(--text-normal);
inset: auto 0 0 0;
@@ -12,29 +12,29 @@
z-index: 1;
@media (prefers-color-scheme: dark) {
background-color: var(--color-always-black);
border-block-start: 1px solid var(--color-subtle-dark);
background-color: var(--color-black);
border-block-start: 1px solid var(--color-ink-lighter);
}
.terminal__input {
--input-border-color: transparent;
--input-border-radius: 0.3em;
--hover-color: transparent;
--input-color: var(--color-positive);
--input-color: var(--color-terminal-text);
--outline-size: 0;
--outline-color: transparent;
accent-color: var(--color-positive);
caret-color: var(--color-positive);
accent-color: var(--color-terminal-text);
caret-color: var(--color-terminal-text);
font-family: inherit;
&::placeholder {
color: var(--color-always-white);
color: var(--color-white);
opacity: 0.5;
}
.terminal__link & {
color: var(--color-always-white);
color: var(--color-white);
opacity: 0.5;
}
}
@@ -43,7 +43,7 @@
.terminal__link {
--hover-size: 0;
color: var(--color-positive);
color: var(--color-terminal-text);
}
.terminal__menu {
+4 -3
View File
@@ -4,7 +4,8 @@
@layer components {
.tray {
--height: calc(2.48lh + var(--block-space));
/* FIXME: Magic number */
--height: calc(2.625lh + var(--block-space));
background-color: transparent;
border: 0;
@@ -61,11 +62,11 @@
.tray__actions {
--border-style: dashed;
--border-color: color-mix(in srgb, var(--color-always-white) 40%, var(--color-ink));
--border-color: color-mix(in srgb, var(--color-white) 40%, var(--color-ink));
--hover-size: 0;
align-items: center;
color: var(--color-ink-reversed);
color: var(--color-ink-inverted);
display: flex;
gap: var(--inline-space-half);
justify-content: space-between;
+4
View File
@@ -0,0 +1,4 @@
.users-list {
max-block-size: 40dvh;
overflow: auto;
}
+11 -9
View File
@@ -14,15 +14,16 @@
.txt-align-end { text-align: end; }
.txt-ink { color: var(--color-ink); }
.txt-reversed { color: var(--color-ink-reversed); }
.txt-reversed { color: var(--color-ink-inverted); }
.txt-negative { color: var(--color-negative); }
.txt-subtle { color: var(--color-subtle-dark); }
.txt-subtle { color: var(--color-ink-medium); }
.txt-alert { color: var(--color-marker); }
.txt-undecorated { text-decoration: none; }
.txt-tight-lines { line-height: 1.2; }
.txt-nowrap { white-space: nowrap; }
.txt-break { word-break: break-word; }
.txt-uppercase { text-transform: uppercase; }
.txt-capitalize { text-transform: capitalize; }
.txt-link { color: var(--color-link); text-decoration: underline; }
/* Flexbox and Grid */
@@ -107,6 +108,7 @@
.pad-inline-double { padding-inline: var(--inline-space-double); }
.unpad { padding: 0; }
.unpad-block-end { padding-block-end: 0; }
.unpad-inline { padding-inline: 0; }
/* Margins */
@@ -146,10 +148,10 @@
.position-sticky { position: sticky; inset: var(--inset, 0 auto auto auto); z-index: var(--z, 1); }
/* Fills */
.fill { background-color: var(--color-bg); }
.fill { background-color: var(--color-canvas); }
.fill-black { background-color: var(--color-ink); }
.fill-white { background-color: var(--color-ink-reversed); }
.fill-shade { background-color: var(--color-subtle-light); }
.fill-white { background-color: var(--color-ink-inverted); }
.fill-shade { background-color: var(--color-ink-lightest); }
.fill-selected { background-color: var(--color-selected); }
.fill-highlight { background-color: var(--color-highlight); }
.fill-transparent { background-color: transparent; }
@@ -157,10 +159,10 @@
.translucent { opacity: var(--opacity, 0.66); }
/* Borders */
.border { border: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
.border-block { border-block: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
.border-bottom { border-block-end: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
.border-top { border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
.border { border: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); }
.border-block { border-block: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); }
.border-bottom { border-block-end: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); }
.border-top { border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-ink-lighter)); }
.borderless { border: 0; }
/* Border radius */
+39 -6
View File
@@ -20,20 +20,53 @@
.workflow-stage--current {
--btn-background: var(--card-color);
color: var(--color-ink-reversed);
color: var(--color-ink-inverted);
@media (hover: hover) {
&:hover {
background-color: color(from var(--card-color) srgb r g b / 0.65);
}
}
}
.ticket & {
--btn-background: var(--color-ink);
.workflow-preview {
--border-size: 3px;
background: var(--card-color);
border-radius: 0.2em;
color: var(--color-ink-reversed);
aspect-ratio: 1;
flex: 1 1 30%;
max-inline-size: 33%;
button {
--hover-size: 0;
appearance: none;
aspect-ratio: 1;
background: transparent;
block-size: 100%;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.5em;
inline-size: 100%;
margin: 0;
max-inline-size: 100%;
outline: 0;
padding: 1em;
text-align: start;
}
}
.workflow-preview--selected {
--border-color: var(--color-link);
}
.workflow-preview__swatch {
--btn-size: 1em;
--btn-border-radius: 50%;
--btn-border-size: 0;
--hover-size: 0;
cursor: auto;
}
}
@@ -6,5 +6,6 @@ class Cards::AssignmentsController < ApplicationController
def create
@card.toggle_assignment @collection.users.active.find(params[:assignee_id])
render_card_replacement
end
end
+2 -2
View File
@@ -3,11 +3,11 @@ class Cards::ClosuresController < ApplicationController
def create
@card.close(user: Current.user, reason: params[:reason])
redirect_to @card
render_card_replacement
end
def destroy
@card.reopen
redirect_to @card
render_card_replacement
end
end
@@ -25,7 +25,7 @@ class Cards::Comments::ReactionsController < ApplicationController
private
def set_comment
@comment = Comment.belonging_to_card(@card).find(params[:comment_id])
@comment = @card.comments.find(params[:comment_id])
end
def broadcast_create(reaction)
+2 -2
View File
@@ -5,7 +5,7 @@ class Cards::CommentsController < ApplicationController
before_action :ensure_creatorship, only: %i[ edit update destroy ]
def create
@card.capture Comment.new(comment_params)
@card.comments.create!(comment_params)
end
def show
@@ -25,7 +25,7 @@ class Cards::CommentsController < ApplicationController
private
def set_comment
@comment = Comment.belonging_to_card(@card).find(params[:id])
@comment = @card.comments.find(params[:id])
end
def ensure_creatorship
@@ -3,11 +3,11 @@ class Cards::EngagementsController < ApplicationController
def create
@card.engage
redirect_to @card
render_card_replacement
end
def destroy
@card.reconsider
redirect_to @card
render_card_replacement
end
end
@@ -3,11 +3,11 @@ class Cards::GoldnessesController < ApplicationController
def create
@card.gild
redirect_to @card
render_card_replacement
end
def destroy
@card.ungild
redirect_to @card
render_card_replacement
end
end
+1 -1
View File
@@ -3,6 +3,6 @@ class Cards::ImagesController < ApplicationController
def destroy
@card.image.purge_later
redirect_to @card
render_card_replacement
end
end
+14 -10
View File
@@ -5,25 +5,29 @@ class Cards::PinsController < ApplicationController
end
def create
pin = @card.pin_by Current.user
@pin = @card.pin_by Current.user
broadcast_my_new pin
redirect_to card_pin_path(@card)
broadcast_add_pin_to_tray
render_pin_button_replacement
end
def destroy
pin = @card.unpin_by Current.user
@pin = @card.unpin_by Current.user
broadcast_my_removed pin
redirect_to card_pin_path(@card)
broadcast_remove_pin_from_tray
render_pin_button_replacement
end
private
def broadcast_my_new(pin)
pin.card.broadcast_prepend_later_to [ Current.user, :pins ], target: "pins", partial: "cards/display/preview"
def broadcast_add_pin_to_tray
@pin.broadcast_prepend_to [ Current.user, :pins_tray ], target: "pins", partial: "my/pins/pin"
end
def broadcast_my_removed(pin)
pin.broadcast_remove_to [ Current.user, :pins ]
def broadcast_remove_pin_from_tray
@pin.broadcast_remove_to [ Current.user, :pins_tray ]
end
def render_pin_button_replacement
render turbo_stream: turbo_stream.replace([ @card, :pin_button ], partial: "cards/pins/pin_button", locals: { card: @card })
end
end
+1 -7
View File
@@ -2,12 +2,6 @@ class Cards::ReadingsController < ApplicationController
include CardScoped
def create
mark_card_notifications_read
@notifications = Current.user.notifications.unread.ordered.limit(20)
@notifications = @card.read_by(Current.user)
end
private
def mark_card_notifications_read
Current.user.notifications.unread.where(card: @card).read_all
end
end
+3 -13
View File
@@ -1,18 +1,8 @@
class Cards::StagingsController < ApplicationController
include CardScoped
before_action :set_stage
def create
if @stage
@card.toggle_stage @stage
else
@card.update!(stage: nil)
end
def update
@card.change_stage_to @collection.workflow.stages.find(params[:stage_id])
render_card_replacement
end
private
def set_stage
@stage = Workflow::Stage.find_by(id: params[:stage_id])
end
end
@@ -7,6 +7,7 @@ class Cards::TaggingsController < ApplicationController
def create
@card.toggle_tag_with sanitized_tag_title_param
render_card_replacement
end
private
+15 -6
View File
@@ -25,16 +25,21 @@ class CardsController < ApplicationController
def edit
end
def update
@card.update! card_params
if @card.published?
render_card_replacement
else
redirect_to @card
end
end
def destroy
@card.destroy!
redirect_to cards_path(collection_ids: [ @card.collection ]), notice: ("Card deleted" unless @card.creating?)
end
def update
@card.update! card_params
redirect_to @card
end
private
def set_card
@card = @collection.cards.find params[:id]
@@ -49,6 +54,10 @@ class CardsController < ApplicationController
end
def card_params
params.expect(card: [ :status, :title, :color, :due_on, :image, :draft_comment, tag_ids: [] ])
params.expect(card: [ :status, :title, :description, :image, tag_ids: [] ])
end
def render_card_replacement
render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", locals: { card: @card.reload })
end
end
@@ -10,6 +10,6 @@ class Collections::WorkflowsController < ApplicationController
private
def set_workflow
@workflow = Workflow.find(params.expect(collection: [ :workflow_id ]).require(:workflow_id))
@workflow = Workflow.find(params[:collection][:workflow_id])
end
end
+3 -3
View File
@@ -6,7 +6,7 @@ class CollectionsController < ApplicationController
end
def create
@collection = Collection.create! collection_params
@collection = Collection.create! collection_params.with_defaults(all_access: true)
redirect_to cards_path(collection_ids: [ @collection ])
end
@@ -19,7 +19,7 @@ class CollectionsController < ApplicationController
@collection.update! collection_params
@collection.accesses.revise granted: grantees, revoked: revokees
redirect_to cards_path(collection_ids: [ @collection ])
redirect_to edit_collection_path(@collection), notice: "Collection updated"
end
def destroy
@@ -33,7 +33,7 @@ class CollectionsController < ApplicationController
end
def collection_params
params.expect(collection: [ :name, :all_access ]).with_defaults(all_access: true)
params.expect(collection: [ :name, :all_access, :auto_close_period ])
end
def grantees
+4
View File
@@ -13,4 +13,8 @@ module CardScoped
def set_collection
@collection = @card.collection
end
def render_card_replacement
render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload })
end
end
@@ -1,24 +0,0 @@
module CollectionFilterable
extend ActiveSupport::Concern
included do
before_action :set_collection_filter
end
private
def set_collection_filter
params[:collection_ids] ||= cookies[:collection_filter]&.split(",") unless params[:clear_filter]
end
def collection_filter
params[:collection_ids].presence || Current.user.collection_ids
end
def update_collection_filter
if params[:clear_filter]
cookies.delete(:collection_filter)
elsif params[:collection_ids].present?
cookies[:collection_filter] = params[:collection_ids].join(",")
end
end
end
@@ -0,0 +1,48 @@
module DayTimelinesScoped
extend ActiveSupport::Concern
included do
include FilterScoped
before_action :restore_collections_filter_from_cookie
before_action :set_day_timeline
after_action :save_collections_filter_to_cookie
end
private
def restore_collections_filter_from_cookie
if params[:clear_filter]
delete_collections_filter_cookie
else
set_collections_filter_from_cookie
end
end
def delete_collections_filter_cookie
cookies.delete(:collection_filter)
end
def set_collections_filter_from_cookie
if cookies[:collection_filter].present? && @filter.collections.blank?
@filter.collection_ids = cookies[:collection_filter].split(",")
end
end
def set_day_timeline
@day_timeline = Current.user.timeline_for(day, filter: @filter)
end
def save_collections_filter_to_cookie
cookies[:collection_filter] = @filter.collection_ids.join(",")
end
def day
if params[:day].present?
Time.zone.parse(params[:day])
else
Time.current
end
rescue ArgumentError
head :not_found
end
end
@@ -1,40 +0,0 @@
module EventsTimeline
extend ActiveSupport::Concern
include CollectionFilterable
included do
before_action :set_activity_day
end
private
def set_activity_day
@activity_day = if params[:day].present?
Time.zone.parse(params[:day])
else
Time.zone.now
end
rescue ArgumentError
raise ActionController::RoutingError
end
def events_for_activity_day
user_events.where(created_at: @activity_day.all_day)
.group_by { |event| [ event.created_at.hour, helpers.event_column(event) ] }
.map { |hour_col, events| [ hour_col, events.uniq { |e| e.id } ] }
end
def latest_event_before_activity_day
user_events.where(created_at: ...@activity_day.beginning_of_day).chronologically.last
end
def user_events
Event.where(card: user_cards)
end
def user_cards
Current.user.accessible_cards
.published_or_drafted_by(Current.user)
.where(collection_id: collection_filter)
end
end
+1 -3
View File
@@ -1,8 +1,6 @@
class Events::DaysController < ApplicationController
include EventsTimeline
include DayTimelinesScoped
def index
@events = events_for_activity_day
@next_day = latest_event_before_activity_day&.created_at
end
end
+1 -7
View File
@@ -1,13 +1,7 @@
class EventsController < ApplicationController
include EventsTimeline
include DayTimelinesScoped
def index
update_collection_filter
@collections = Current.user.collections.alphabetically
@filters = Current.user.filters.all
@events = events_for_activity_day
@next_day = latest_event_before_activity_day&.created_at
end
end
@@ -1,18 +0,0 @@
class Notifications::MarkAllReadsController < ApplicationController
def create
Current.user.notifications.unread.read_all
set_page_and_extract_portion_from Current.user.notifications.read.ordered if request.format.turbo_stream?
Turbo::StreamsChannel.broadcast_update_to(
[ Current.user, :notifications ],
target: "notifications",
html: ""
)
respond_to do |format|
format.turbo_stream
format.html
end
end
end
@@ -0,0 +1,11 @@
class Notifications::ReadingsController < ApplicationController
def create
@notification = Current.user.notifications.find(params[:id])
@notification.read
end
def create_all
Current.user.notifications.unread.read_all
redirect_to notifications_path
end
end
@@ -1,7 +1,5 @@
module Notifications
class SettingsController < ApplicationController
def show
@collections = Current.user.collections.alphabetically
end
class Notifications::SettingsController < ApplicationController
def show
@collections = Current.user.collections.alphabetically
end
end
@@ -1,5 +1,5 @@
class Notifications::TraysController < ApplicationController
def show
@notifications = Current.user.notifications.unread.ordered.limit(20)
@notifications = Current.user.notifications.unread.ordered.limit(100)
end
end
+3 -11
View File
@@ -1,19 +1,11 @@
class NotificationsController < ApplicationController
def index
@unread = Current.user.notifications.unread.ordered unless current_page_param
set_page_and_extract_portion_from Current.user.notifications.read.ordered
if @page.first?
@unread = Current.user.notifications.unread.ordered
end
end
def mark_read
@notification = Current.user.notifications.find(params[:id])
@notification.update!(read_at: Time.current)
respond_to do |format|
format.html { redirect_back fallback_location: notifications_path }
format.turbo_stream
format.turbo_stream if current_page_param # Allows read-all action to side step pagination
format.html
end
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class TerminalsController < ApplicationController
def show
@events = Event.where(card: Current.user.accessible_cards, creator: Current.user).chronologically.reverse_order.limit(20)
@events = Current.user.accessible_events.chronologically.reverse_order.limit(20)
end
def edit
+6 -6
View File
@@ -2,8 +2,8 @@ class UsersController < ApplicationController
require_unauthenticated_access only: %i[ new create ]
before_action :set_user, only: %i[ show edit update destroy ]
before_action :set_account_from_join_code, only: %i[ new create ]
before_action :ensure_permission_to_administer_user, only: %i[ update destroy ]
before_action :ensure_join_code_is_valid, only: %i[ new create ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
def index
@users = User.active
@@ -36,16 +36,16 @@ class UsersController < ApplicationController
end
private
def set_account_from_join_code
@account = Account.find_by_join_code!(params[:join_code])
def ensure_join_code_is_valid
head :forbidden unless Account.sole.join_code == params[:join_code]
end
def set_user
@user = User.active.find(params[:id])
end
def ensure_permission_to_administer_user
head :forbidden unless Current.user.can_administer?(@user)
def ensure_permission_to_change_user
head :forbidden unless Current.user.can_change?(@user)
end
def user_params
-2
View File
@@ -11,8 +11,6 @@ class WorkflowsController < ApplicationController
def create
@workflow = Workflow.create! workflow_params
# FIXME: this should definitely change.
[ "Triage", "In progress", "On Hold", "Review" ].each { |name| @workflow.stages.create! name: name }
redirect_to workflows_path
end
+1 -1
View File
@@ -1,6 +1,6 @@
module AccessesHelper
def access_menu_tag(collection, &)
tag.menu class: [ "flex flex-column gap margin-none pad txt-medium", { "toggler--toggled": collection.all_access? } ], data: {
tag.menu class: [ "flex flex-column gap-half unpad margin-none txt-medium", { "toggler--toggled": collection.all_access? } ], data: {
controller: "filter toggle-class",
filter_active_class: "filter--active", filter_selected_class: "selected",
toggle_class_toggle_class: "toggler--toggled" }, &
+21 -2
View File
@@ -15,11 +15,12 @@ module CardsHelper
**options
end
def card_article_tag(card, id: dom_id(card, :ticket), **options, &block)
def card_article_tag(card, id: dom_id(card, :article), **options, &block)
classes = [
options.delete(:class),
("card--golden" if card.golden?),
("card--doing" if card.doing?)
("card--doing" if card.doing?),
("card--drafted" if card.drafted?)
].compact.join(" ")
tag.article \
@@ -29,4 +30,22 @@ module CardsHelper
**options,
&block
end
def button_to_delete_card(card)
button_to collection_card_path(card.collection, card),
method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_frame: "_top", turbo_confirm: "Are you sure you want to permanently delete this card?" } do
concat(icon_tag("trash"))
concat(tag.span("Delete this card"))
end
end
def card_title_tag(card)
title = [
card.title,
"added by #{card.creator.name}",
"in #{card.collection.name}"
]
title << "assigned to #{card.assignees.map(&:name).to_sentence}" if card.assignees.any?
title.join(" ")
end
end
+12
View File
@@ -0,0 +1,12 @@
module CollectionsHelper
def collection_auto_close_options
[
[ "30 days", 30.days ],
[ "60 days", 60.days ],
[ "90 days", 90.days ],
[ "6 months", 180.days ],
[ "1 year", 365.days ],
[ "Never", nil ]
]
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
module CommentsHelper
def new_comment_placeholder(card)
if card.creator == Current.user && card.messages.comments.empty?
if card.creator == Current.user && card.comments.empty?
"Next, add some notes, context, pictures, or video about this…"
else
"Type your comment…"
+51 -53
View File
@@ -1,20 +1,9 @@
module EventsHelper
def event_day_title(day)
case
when day.today?
"Today"
when day.yesterday?
"Yesterday"
else
day.strftime("%A, %B %e")
end
end
def event_column(event)
case event.action
when "closed"
when "card_closed"
3
when "published"
when "card_published"
1
else
2
@@ -33,7 +22,7 @@ module EventsHelper
end
end
def render_event_grid_cells(day, columns: 4, rows: 24)
def render_event_grid_cells(columns: 4, rows: 24)
safe_join((2..rows + 1).map do |row|
(1..columns).map do |col|
tag.div class: class_names("event__grid-item"), style: "grid-area: #{row}/#{col};"
@@ -41,19 +30,11 @@ module EventsHelper
end.flatten)
end
def render_column_headers(day = Date.current)
start_time = day.beginning_of_day
end_time = day.end_of_day
accessible_events = Event.joins(card: :collection)
.merge(Current.user.collections)
.where(created_at: start_time..end_time)
.where(cards: { collection_id: params[:collection_ids].presence || Current.user.collection_ids })
def render_column_headers(day_timeline)
headers = {
"Added" => accessible_events.where(action: "published").count,
"Added" => day_timeline.events.where(action: "card_published").count,
"Updated" => nil,
"Closed" => accessible_events.where(action: "closed").count
"Closed" => day_timeline.events.where(action: "card_closed").count
}
headers.map do |header, count|
@@ -63,47 +44,64 @@ module EventsHelper
end
def event_action_sentence(event)
if event.action.comment_created?
comment_event_action_sentence(event)
else
card_event_action_sentence(event)
end
end
def comment_event_action_sentence(event)
"#{ event_creator_name(event) } commented on <span style='color: var(--card-color)'>#{ event.eventable.card.title }</span>".html_safe
end
def event_creator_name(event)
event.creator == Current.user ? "You" : event.creator.name
end
def card_event_action_sentence(event)
card = event.eventable
title = card.title
case event.action
when "assigned"
when "card_assigned"
if event.assignees.include?(Current.user)
"#{ event.creator == Current.user ? "You" : event.creator.name } will handle <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
"#{ event_creator_name(event) } will handle <span style='color: var(--card-color)'>#{ title }</span>".html_safe
else
"#{ event.creator == Current.user ? "You" : event.creator.name } assigned #{ event.assignees.pluck(:name).to_sentence } to <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
"#{ event_creator_name(event) } assigned #{ event.assignees.pluck(:name).to_sentence } to <span style='color: var(--card-color)'>#{ title }</span>".html_safe
end
when "unassigned"
"#{ event.creator == Current.user ? "You" : event.creator.name } unassigned #{ event.assignees.include?(Current.user) ? "yourself" : event.assignees.pluck(:name).to_sentence } from <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "commented"
"#{ event.creator == Current.user ? "You" : event.creator.name } commented on <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "published"
"#{ event.creator == Current.user ? "You" : event.creator.name } added <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "closed"
"#{ event.creator == Current.user ? "You" : event.creator.name } closed <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "staged"
"#{event.creator == Current.user ? "You" : event.creator.name} moved <span style='color: var(--card-color)'>#{ event.card.title }</span> to the #{event.stage_name} stage".html_safe
when "unstaged"
"#{event.creator == Current.user ? "You" : event.creator.name} moved <span style='color: var(--card-color)'>#{ event.card.title }</span> out ofthe #{event.stage_name} stage".html_safe
when "due_date_added"
"#{event.creator == Current.user ? "You" : event.creator.name} set the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "due_date_changed"
"#{event.creator == Current.user ? "You" : event.creator.name} changed the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "due_date_removed"
"#{event.creator == Current.user ? "You" : event.creator.name} removed the date on <span style='color: var(--card-color)'>#{ event.card.title }</span>"
when "title_changed"
"#{event.creator == Current.user ? "You" : event.creator.name} renamed <span style='color: var(--card-color)'>#{ event.card.title }</span> (was: '#{event.particulars.dig('particulars', 'old_title')})'".html_safe
when "card_unassigned"
"#{ event_creator_name(event) } unassigned #{ event.assignees.include?(Current.user) ? "yourself" : event.assignees.pluck(:name).to_sentence } from <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_published"
"#{ event_creator_name(event) } added <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_closed"
"#{ event_creator_name(event) } closed <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_staged"
"#{event_creator_name(event)} moved <span style='color: var(--card-color)'>#{ title }</span> to the #{event.stage_name} stage".html_safe
when "card_unstaged"
"#{event_creator_name(event)} moved <span style='color: var(--card-color)'>#{ title }</span> out ofthe #{event.stage_name} stage".html_safe
when "card_due_date_added"
"#{event_creator_name(event)} set the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_due_date_changed"
"#{event_creator_name(event)} changed the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_due_date_removed"
"#{event_creator_name(event)} removed the date on <span style='color: var(--card-color)'>#{ title }</span>"
when "card_title_changed"
"#{event_creator_name(event)} renamed <span style='color: var(--card-color)'>#{ title }</span> (was: '#{event.particulars.dig('particulars', 'old_title')})'".html_safe
end
end
def event_action_icon(event)
case event.action
when "assigned"
when "card_assigned"
"assigned"
when "staged"
when "card_staged"
"bolt"
when "unstaged"
when "card_unstaged"
"bolt"
when "commented"
when "comment_created"
"comment"
when "title_changed"
when "card_title_changed"
"rename"
else
"person"
+12 -2
View File
@@ -21,9 +21,19 @@ module FiltersHelper
hidden_field_tag name, value, id: nil
end
def filter_selected_collections_label(filter)
selected_collections = if filter.collections.any?
filter.collections.collect { "<strong>#{it.name}</strong>" }.uniq.sort.to_sentence
else
"all collections"
end
"Activity in #{selected_collections}".html_safe
end
def any_filters?(filter)
filter.tags.any? || filter.assignees.any? || filter.creators.any? ||
filter.stages.any? || filter.terms.any? ||
filter.assignment_status.unassigned? || !filter.default_indexed_by?
filter.stages.any? || filter.terms.any? ||
filter.assignment_status.unassigned?
end
end
+25 -25
View File
@@ -1,20 +1,19 @@
module NotificationsHelper
def notification_title(notification)
if notification.resource.is_a? Comment
"RE: " + notification.card.title
elsif notification_event_action(notification) == "assigned"
"Assigned to you: " + notification.card.title
else
notification.card.title
def event_notification_title(event)
case event_notification_action(event)
when "comment_created" then "RE: " + event.eventable.card.title
when "card_assigned" then "Assigned to you: " + event.eventable.title
else event.eventable.title
end
end
def notification_body(notification)
name = notification.creator.name
def event_notification_body(event)
name = event.creator.name
case notification_event_action(notification)
when "closed" then "Closed by #{name}"
when "published" then "Added by #{name}"
case event_notification_action(event)
when "card_closed" then "Closed by #{name}"
when "card_published" then "Added by #{name}"
when "comment_created" then comment_notification_body(event)
else name
end
end
@@ -22,7 +21,7 @@ module NotificationsHelper
def notification_tag(notification, &)
tag.div id: dom_id(notification), class: "notification tray__item border-radius txt-normal" do
concat(
link_to(notification.resource,
link_to(notification,
class: "notification__content border-radius shadow fill-white flex align-start txt-align-start gap flex-item-grow max-width border txt-ink",
data: { action: "click->dialog#close", turbo_frame: "_top" },
&)
@@ -32,12 +31,12 @@ module NotificationsHelper
end
def notification_mark_read_button(notification)
button_to mark_read_notification_path(notification),
class: "notification__unread_indicator btn borderless",
title: "Mark as read",
data: { turbo_frame: "_top" } do
concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
concat(tag.span("Mark as read", class: "for-screen-reader"))
button_to read_notification_path(notification),
class: "notification__unread_indicator btn borderless",
title: "Mark as read",
data: { turbo_frame: "_top" } do
concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
concat(tag.span("Mark as read", class: "for-screen-reader"))
end
end
@@ -48,15 +47,16 @@ module NotificationsHelper
end
private
def notification_event_action(notification)
if notification_is_for_initial_assignement?(notification)
"assigned"
def event_notification_action(event)
if event.action.card_published? && event.eventable.assigned_to?(event.creator)
"card_assigned"
else
notification.event.action
event.action
end
end
def notification_is_for_initial_assignement?(notification)
notification.event.action == "published" && notification.card.assigned_to?(notification.user)
def comment_notification_body(event)
comment = event.eventable
"#{strip_tags(comment.body_html).blank? ? "#{event.creator.name} replied" : "#{event.creator.name}:" } #{strip_tags(comment.body_html).truncate(200)}"
end
end
-6
View File
@@ -2,10 +2,4 @@ module UsersHelper
def prepend_current_user_to(users_scope)
users_scope.to_a.prepend(Current.user).uniq
end
def familiar_name_for(user)
names = user.name.split
return user.name if names.length == 1
"#{names.first} #{names[1..].map { |n| "#{n[0]}." }.join}"
end
end
+8 -9
View File
@@ -1,16 +1,15 @@
module WorkflowsHelper
def button_to_set_stage(card, stage)
button_to card_stagings_path(card, stage_id: stage),
method: :post, class: [ "btn justify-start workflow-stage txt-uppercase workflow-stage", { "workflow-stage--current": stage == card.stage } ],
form_class: "flex align-center gap-half",
data: { turbo_frame: "_top" } do
tag.span class: "overflow-ellipsis" do
stage.name
end
end
button_to \
tag.span(stage.name, class: "overflow-ellipsis"),
card_staging_path(card, stage_id: stage),
method: :put,
class: [ "btn justify-start workflow-stage txt-uppercase workflow-stage", { "workflow-stage--current": stage == card.stage } ],
form_class: "flex align-center gap-half",
data: { turbo_frame: "_top" }
end
def stage_color(stage)
stage.color.presence || Colorable::DEFAULT_COLOR
stage.color.presence || Card::DEFAULT_COLOR
end
end
@@ -1,24 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
static classes = [ "play" ]
static values = { playOnLoad: { type: Boolean, default: false } }
connect() {
if (this.playOnLoadValue) {
this.play()
}
}
async play() {
await nextFrame()
this.element.classList.remove(this.playClass)
this.#forceReflow()
this.element.classList.add(this.playClass)
}
#forceReflow() {
this.element.offsetWidth
}
}
@@ -1,16 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "element" ]
connect() {
this.observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) this.elementTarget.focus()
})
this.observer.observe(this.elementTarget)
}
disconnect() {
this.observer.disconnect()
}
}
@@ -1,43 +0,0 @@
import { Controller } from "@hotwired/stimulus"
const SIZES = [ "one", "two", "three", "four", "five" ]
export default class extends Controller {
static targets = [ "card" ]
connect() {
this.resize()
}
resize() {
const [ min, max ] = this.#getScoreRange()
this.cardTargets.forEach(card => {
const score = this.#currentCardScore(card)
const idx = Math.round((score - min) / (max - min) * (SIZES.length - 1))
card.style.setProperty("--card-size", `var(--card-size-${SIZES[idx]})`)
})
}
#getScoreRange() {
var min = 0, max = 1;
this.cardTargets.forEach(card => {
const score = this.#currentCardScore(card)
min = Math.min(min, score)
max = Math.max(max, score)
})
return [ min, max ]
}
#currentCardScore(el) {
const score = el.dataset.activityScore
const scoreAt = el.dataset.activityScoreAt
const daysAgo = (Date.now() / 1000 - scoreAt) / (60 * 60 * 24)
return score / (2**daysAgo)
}
}
@@ -47,7 +47,7 @@ export default class extends Controller {
#triggerChangeEvent(newValue) {
if (this.inputTarget.tagName === "HOUSE-MD") {
this.inputTarget.dispatchEvent(new CustomEvent('house-md:change', {
cards: true,
bubbles: true,
detail: {
previousContent: '',
newContent: newValue
@@ -2,7 +2,9 @@ import { Controller } from "@hotwired/stimulus"
import { differenceInDays } from "helpers/date_helpers"
export default class extends Controller {
static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays", "daysago" ]
static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays", "daysago", "agoorweekday" ]
static values = { refreshInterval: Number }
static classes = [ "local-time-value"]
#timer
@@ -13,7 +15,10 @@ export default class extends Controller {
this.dateTimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", dateStyle: "short" })
this.agoFormatter = new AgoFormatter()
this.daysagoFormatter = new DaysAgoFormatter()
this.datewithweekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" })
this.datewithweekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" })
this.indaysFormatter = new InDaysFormatter()
this.agoorweekdayFormatter = new DaysAgoOrWeekdayFormatter()
}
connect() {
@@ -60,6 +65,10 @@ export default class extends Controller {
this.#formatTime(this.daysagoFormatter, target)
}
agoorweekdayTargetConnected(target) {
this.#formatTime(this.agoorweekdayFormatter, target)
}
#refreshRelativeTimes() {
this.agoTargets.forEach(target => {
this.#formatTime(this.agoFormatter, target)
@@ -111,6 +120,18 @@ class DaysAgoFormatter {
}
}
class DaysAgoOrWeekdayFormatter {
format(date) {
const days = differenceInDays(date, new Date())
if (days <= 1) {
return new DaysAgoFormatter().format(date)
} else {
return new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" }).format(date)
}
}
}
class InDaysFormatter {
format(date) {
const days = differenceInDays(new Date(), date)
@@ -12,7 +12,7 @@ export default class extends Controller {
for (const file of files) {
const uploadEvent = new CustomEvent("house-md:before-upload", {
cards: true,
bubbles: true,
detail: { file },
cancelable: true
})
@@ -1,10 +1,10 @@
import { Controller } from "@hotwired/stimulus"
import { nextEventNamed } from "helpers/timing_helpers"
import { isTouchDevice } from "helpers/navigator_helpers"
export default class extends Controller {
// Only load for touch devices
static get shouldLoad() {
return isTouchDevice()
return "ontouchstart" in window && navigator.maxTouchPoints > 0
}
// Use a fake input to trigger the soft keyboard on actions that load async content
@@ -6,4 +6,12 @@ export default class extends Controller {
toggle() {
this.element.classList.toggle(this.toggleClass)
}
add() {
this.element.classList.add(this.toggleClass)
}
remove() {
this.element.classList.remove(this.toggleClass)
}
}
@@ -1,3 +0,0 @@
export function isTouchDevice() {
return "ontouchstart" in window && navigator.maxTouchPoints > 0
}
@@ -1,8 +0,0 @@
import { Turbo } from "@hotwired/turbo-rails"
Turbo.StreamActions.set_css_variable = function() {
const name = this.getAttribute("name")
const value = this.getAttribute("value")
this.targetElements.forEach(element => element.style.setProperty(name, value))
}
-1
View File
@@ -1,2 +1 @@
import "initializers/current"
import "initializers/custom_stream_actions"
-2
View File
@@ -1,6 +1,4 @@
class Card::AutoCloseAllDueJob < ApplicationJob
queue_as :default
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_close_all_due
@@ -1,6 +1,4 @@
class Card::AutoReconsiderAllStagnatedJob < ApplicationJob
queue_as :default
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_reconsider_all_stagnated
-7
View File
@@ -1,7 +0,0 @@
class GenerateNotificationsJob < ApplicationJob
queue_as :default
def perform(event)
event.generate_notifications
end
end
+5
View File
@@ -0,0 +1,5 @@
class Mention::CreateJob < ApplicationJob
def perform(record, mentioner:)
record.create_mentions(mentioner:)
end
end
+5
View File
@@ -0,0 +1,5 @@
class NotifyRecipientsJob < ApplicationJob
def perform(notifiable)
notifiable.notify_recipients
end
end
@@ -1,6 +1,4 @@
class RemoveAbandonedCreationsJob < ApplicationJob
queue_as :default
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.remove_abandoned_creations
+101
View File
@@ -0,0 +1,101 @@
#
# This job backs up all the tenant databases using the SQLite Backup API, which should allow the
# application to continue running against the database while it is backed up.
#
# ref: https://www.sqlite.org/c3ref/backup_finish.html
#
# It will keep N files around, like this:
#
# storage/tenants/development/honcho/db/
# ├─ main.sqlite3
# ├─ main.sqlite3.1
# ├─ main.sqlite3.2
# ├─ main.sqlite3.3
# ├─ main.sqlite3.4
# └─ main.sqlite3.5
#
class SQLiteBackupsJob < ApplicationJob
DEFAULT_NUMBER_OF_BACKUPS = 5
DEFAULT_STEP_PAGES = 1024
def perform(keep: DEFAULT_NUMBER_OF_BACKUPS, step: DEFAULT_STEP_PAGES)
@failures = []
ApplicationRecord.with_each_tenant do |tenant|
perform_file_rollover tenant, keep: keep
perform_backup tenant, step: step
end
if @failures.present?
raise "SQLiteBackupsJob: failed to backup tenants: #{@failures.join(", ")}"
end
end
private
def perform_file_rollover(tenant, keep:)
keep.downto(2) do |j|
fresher = backup_path(tenant, j - 1)
staler = backup_path(tenant, j)
if j == keep && File.exist?(staler)
FileUtils.rm(staler)
end
if File.exist?(fresher)
# TODO: It may be worth benchmarking whether backing up into the previous backup is faster
# than backing up into an empty file.
FileUtils.mv(fresher, staler)
end
end
end
def perform_backup(tenant, step:)
ApplicationRecord.with_connection do |conn|
current_adapter = conn.raw_connection
backup_db = backup_path(tenant, 1)
backup_adapter = SQLite3::Database.new(backup_db)
backup = SQLite3::Backup.new(backup_adapter, "main", current_adapter, "main")
pages = 0
elapsed = ActiveSupport::Benchmark.realtime(:float_millisecond) do
loop do
status = backup.step(step)
case status
when SQLite3::Constants::ErrorCode::DONE
break
when SQLite3::Constants::ErrorCode::OK
total = backup.pagecount
progress = total - backup.remaining
Rails.logger.debug { "SQLiteBackupsJob: #{tenant.inspect}: Wrote #{progress} of #{total} pages." }
when SQLite3::Constants::ErrorCode::BUSY, SQLite3::Constants::ErrorCode::LOCKED
Rails.logger.debug { "SQLiteBackupsJob: #{tenant.inspect}: Busy, retrying." }
else
Rails.logger.error "SQLiteBackupsJob: #{tenant.inspect}: Failed with status #{status}."
@failures << tenant
end
end
pages = backup.pagecount
backup.finish
end
message = sprintf(
"SQLiteBackupsJob: %{tenant}: Backup complete in %<elapsed>.1f ms. Wrote %{pages} pages to %{path}",
tenant: tenant.inspect, path: backup_db.inspect, pages: pages, elapsed: elapsed
)
Rails.logger.info message
end
end
def backup_path(tenant, index)
db_path(tenant) + ".#{index}"
end
def db_path(tenant)
db_config.database
end
def db_config
ApplicationRecord.connection_pool.db_config
end
end
+10 -52
View File
@@ -1,39 +1,27 @@
class Card < ApplicationRecord
include Assignable, Colored, DraftCommenting, Engageable, Eventable, Golden,
Messages, Notifiable, Pinnable, Closeable, Scorable, Searchable, Staged,
include Assignable, Colored, Engageable, Eventable, Golden,
Mentions, Pinnable, Closeable, Readable, Searchable, Staged,
Statuses, Taggable, Watchable
belongs_to :collection, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :notifications, dependent: :destroy
has_many :comments, dependent: :destroy
has_one_attached :image, dependent: :purge_later
after_save :track_due_date_change, if: :saved_change_to_due_on?
after_save :track_title_change, if: :saved_change_to_title?
after_create :assign_initial_stage
has_markdown :description
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
scope :chronologically, -> { order created_at: :asc, id: :asc }
scope :latest, -> { order updated_at: :desc, id: :desc }
scope :in_collection, ->(collection) { where collection: collection }
scope :indexed_by, ->(index) do
case index
when "most_active" then ordered_by_activity
when "newest" then reverse_chronologically
when "oldest" then chronologically
when "latest" then latest
when "stalled" then ordered_by_staleness
when "closed" then closed
end
end
scope :by_engagement_status, ->(status) do
case status.to_s
when "considering" then considering
when "doing" then doing.with_golden_first
when "newest" then reverse_chronologically
when "oldest" then chronologically
when "latest" then latest
when "stalled" then chronologically
when "closed" then closed
end
end
@@ -42,36 +30,6 @@ class Card < ApplicationRecord
end
def cache_key
[ super, collection&.name ].compact.join("/")
[ super, collection.name ].compact.join("/")
end
private
def track_due_date_change
if due_on.present?
if due_on_before_last_save.nil?
track_event("due_date_added", particulars: { due_date: due_on })
else
track_event("due_date_changed", particulars: { due_date: due_on })
end
elsif due_on_before_last_save.present?
track_event("due_date_removed")
end
end
def track_title_change
if title_before_last_save.present?
track_event("title_changed", particulars: {
old_title: title_before_last_save,
new_title: title
})
end
end
def assign_initial_stage
if workflow_stage = collection.workflow&.stages&.first
self.stage = workflow_stage
save! touch: false
track_event :staged, stage_id: workflow_stage.id, stage_name: workflow_stage.name
end
end
end
+7 -4
View File
@@ -8,10 +8,13 @@ module Card::Closeable
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :active, -> { where.missing(:closure) }
scope :open, -> { where.missing(:closure) }
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
scope :due_to_be_closed, -> { considering.where(last_active_at: ..AUTO_CLOSE_AFTER.ago) }
scope :in_auto_closing_collection, -> { joins(:collection).merge(Collection.auto_closing) }
scope :due_to_be_closed, -> { considering.in_auto_closing_collection.where("last_active_at <= DATETIME('now', '-' || auto_close_period || ' seconds')") }
delegate :auto_closing?, :auto_close_period, to: :collection
end
class_methods do
@@ -23,7 +26,7 @@ module Card::Closeable
end
def auto_close_at
last_active_at + AUTO_CLOSE_AFTER if last_active_at
last_active_at + auto_close_period if auto_closing? && last_active_at
end
def days_until_close
@@ -38,7 +41,7 @@ module Card::Closeable
closure.present?
end
def active?
def open?
!closed?
end
+4 -1
View File
@@ -1,8 +1,11 @@
module Card::Colored
extend ActiveSupport::Concern
COLORS = %w[ #3b3633 #67695e #eb7a32 #bf7c2b #c09c6f #746b1e #2c6da8 #5d618f #663251 #ff63a8 ]
DEFAULT_COLOR = "#2c6da8"
def color
color_from_stage || Colorable::DEFAULT_COLOR
color_from_stage || DEFAULT_COLOR
end
private
-33
View File
@@ -1,33 +0,0 @@
module Card::DraftCommenting
extend ActiveSupport::Concern
included do
after_save :capture_draft_comment
end
def draft_comment
find_or_build_initial_comment.body.content
end
def draft_comment=(body)
if body.present?
@draft_comment = body
else
messages.comments.destroy_all
end
end
private
def find_or_build_initial_comment
message = messages.comments.first || messages.new(messageable: Comment.new)
message.comment
end
def capture_draft_comment
if @draft_comment.present?
find_or_build_initial_comment.update! body: @draft_comment, creator: creator
end
@draft_comment = nil
end
end
+12 -8
View File
@@ -6,17 +6,21 @@ module Card::Engageable
included do
has_one :engagement, dependent: :destroy, class_name: "Card::Engagement"
scope :doing, -> { published.active.joins(:engagement) }
scope :considering, -> { published_or_drafted_by(Current.user).active.where.missing(:engagement) }
scope :considering, -> { published_or_drafted_by(Current.user).open.where.missing(:engagement) }
scope :doing, -> { published.open.joins(:engagement) }
scope :stagnated, -> { doing.where(last_active_at: ..STAGNATED_AFTER.ago) }
scope :stagnated, -> { doing.where(last_active_at: ..STAGNATED_AFTER.ago) }
scope :by_engagement_status, ->(status) do
case status.to_s
when "considering" then considering
when "doing" then doing.with_golden_first
end
end
end
class_methods do
def auto_reconsider_all_stagnated
stagnated.find_each do |card|
card.reconsider
end
stagnated.find_each(&:reconsider)
end
end
@@ -25,11 +29,11 @@ module Card::Engageable
end
def doing?
active? && published? && engagement.present?
open? && published? && engagement.present?
end
def considering?
active? && published? && !doing?
open? && published? && engagement.blank?
end
def engage
+19 -12
View File
@@ -1,26 +1,33 @@
module Card::Eventable
extend ActiveSupport::Concern
include ::Eventable
included do
has_many :events, dependent: :destroy
before_create { self.last_active_at = Time.current }
after_save :track_title_change, if: :saved_change_to_title?
end
def touch_last_active_at
touch :last_active_at
end
def track_event(action, creator: Current.user, **particulars)
if published?
event = find_or_capture_event_summary.events.create! action: action, creator: creator, card: self, particulars: particulars
event.generate_notifications_later
def event_was_created(event)
transaction do
create_system_comment_for(event)
touch(:last_active_at)
end
end
private
def find_or_capture_event_summary
transaction do
messages.last&.event_summary || capture(EventSummary.new).event_summary
def should_track_event?
published?
end
def track_title_change
if title_before_last_save.present?
track_event "title_changed", particulars: { old_title: title_before_last_save, new_title: title }
end
end
def create_system_comment_for(event)
SystemCommenter.new(self, event).comment
end
end
@@ -0,0 +1,57 @@
class Card::Eventable::SystemCommenter
attr_reader :card, :event
def initialize(card, event)
@card, @event = card, event
end
def comment
return unless comment_body.present?
if comment = find_replaceable_system_comment
comment.update! body: comment_body
else
card.comments.create! creator: User.system, body: comment_body, created_at: event.created_at
end
end
private
RELATED_EVENTS = [
%w[ card_assigned card_unassigned ]
]
def comment_body
case event.action
when "card_assigned"
"Assigned to #{card.assignees.pluck(:name).to_sentence}."
when "card_unassigned"
if card.assignees.empty?
"Unassigned from #{event.assignees.pluck(:name).to_sentence}."
else
"Assigned to #{card.assignees.pluck(:name).to_sentence}."
end
when "card_staged"
"#{event.creator.name} moved this to '#{event.stage_name}'."
when "card_closed"
"Closed by #{ event.creator.name }"
when "card_title_changed"
"#{event.creator.name} changed title from '#{event.particulars.dig('particulars', 'old_title')}' to '#{event.particulars.dig('particulars', 'new_title')}'."
end
end
def find_replaceable_system_comment
previous_comment, previous_event = card.comments.last, card.events[-2]
if previous_comment&.creator&.system? && replaceable_event?(previous_event, event)
previous_comment
end
end
def replaceable_event?(candidate_event, replacement_event)
candidate_event && replacement_event &&
(candidate_event.action == replacement_event.action || related_events?(candidate_event, replacement_event))
end
def related_events?(candidate_event, replacement_event)
RELATED_EVENTS.find { |related_actions| related_actions.include?(candidate_event.action) && related_actions.include?(replacement_event.action) }
end
end
+1 -2
View File
@@ -2,10 +2,9 @@ module Card::Golden
extend ActiveSupport::Concern
included do
scope :golden, -> { joins(:goldness) }
has_one :goldness, dependent: :destroy, class_name: "Card::Goldness"
scope :golden, -> { joins(:goldness) }
scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL") }
end
-11
View File
@@ -1,11 +0,0 @@
module Card::Messages
extend ActiveSupport::Concern
included do
has_many :messages, -> { chronologically }, dependent: :destroy
end
def capture(messageable)
messages.create! messageable: messageable
end
end

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