Merge branch 'main' into sqlite

* main: (116 commits)
  Ensure avatar thumbnails are square
  Update useragent to recognize twitterbot/facebot
  Add defensive styles for non-square avatar images
  Update test for copy changes
  Missed commit
  AI: standardize on https://agents.md
  Make it clear this is just notifications, not comprehensive activity
  AI: configure MCP servers for Chrome, Grafana, and Sentry (#1727)
  Allow requests from Google Image Proxy
  Update to basecamp's useragent fork
  Clean up a little bit the CSRF reporting code
  Claude: production observability guidance (#1725)
  Prevent autoscroll to the root columns container to prevent jump on page load
  Include full name string so you can type your name to filter
  Prioritize current user and assigned users in assignment dropdown
  Check and report on Sec-Fetch-Site header for forgery protection
  bundle update
  Bump bootsnap from 1.18.6 to 1.19.0
  Bump rails from `077c3ad` to `17f6e00`
  Fix cards getting stuck in edit mode
  ...
This commit is contained in:
Donal McBreen
2025-11-26 10:03:42 +00:00
134 changed files with 1395 additions and 695 deletions
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env ruby
def exit_with_error(message)
$stderr.puts message
exit 1
end
def check_branch
if ENV["KAMAL_DESTINATION"] == "production"
current_branch = `git branch --show-current`.strip
if current_branch != "main"
exit_with_error "Only the `main` branch should be deployed to production, current branch is #{current_branch}. If this is expected, try again with `SKIP_GIT_CHECKS=1` prepended to the command"
end
end
end
def check_for_uncommitted_changes
if `git status --porcelain`.strip.length != 0
exit_with_error "You have uncommitted changes, aborting"
end
end
def check_local_and_remote_heads_match
remote_head = `git ls-remote origin --tags $(git branch --show-current) | cut -f1 | head -1`.strip
local_head = `git rev-parse HEAD`.strip
if local_head != remote_head
exit_with_error "Remote HEAD #{remote_head}, differs from local HEAD #{local_head}, aborting"
end
end
unless ENV["SKIP_GIT_CHECKS"]
check_branch
check_for_uncommitted_changes
check_local_and_remote_heads_match
end
+7 -1
View File
@@ -1,6 +1,12 @@
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY)
SECRETS=$(kamal secrets fetch --adapter 1password --account basecamp --from Deploy/Fizzy Deployments/BASECAMP_REGISTRY_PASSWORD Deployments/DASH_BASIC_AUTH_SECRET Beta/RAILS_MASTER_KEY Beta/MYSQL_ALTER_PASSWORD Beta/MYSQL_ALTER_USER Beta/MYSQL_APP_PASSWORD Beta/MYSQL_APP_USER Beta/MYSQL_READONLY_PASSWORD Beta/MYSQL_READONLY_USER)
GITHUB_TOKEN=$(gh config get -h github.com oauth_token)
BASECAMP_REGISTRY_PASSWORD=$(kamal secrets extract BASECAMP_REGISTRY_PASSWORD $SECRETS)
DASH_BASIC_AUTH_SECRET=$(kamal secrets extract DASH_BASIC_AUTH_SECRET $SECRETS)
RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY $SECRETS)
MYSQL_ALTER_PASSWORD=$(kamal secrets extract MYSQL_ALTER_PASSWORD $SECRETS)
MYSQL_ALTER_USER=$(kamal secrets extract MYSQL_ALTER_USER $SECRETS)
MYSQL_APP_PASSWORD=$(kamal secrets extract MYSQL_APP_PASSWORD $SECRETS)
MYSQL_APP_USER=$(kamal secrets extract MYSQL_APP_USER $SECRETS)
MYSQL_READONLY_PASSWORD=$(kamal secrets extract MYSQL_READONLY_PASSWORD $SECRETS)
MYSQL_READONLY_USER=$(kamal secrets extract MYSQL_READONLY_USER $SECRETS)
+18
View File
@@ -0,0 +1,18 @@
{
"mcpServers": {
"chrome-devtools": {
"type": "stdio",
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest"]
},
"sentry": {
"type": "stdio",
"command": "npx",
"args": ["-y", "mcp-remote@latest", "https://mcp.sentry.dev/mcp"]
},
"grafana": {
"type": "http",
"url": "https://grafana.37signals.com/mcp"
}
}
}
+179
View File
@@ -0,0 +1,179 @@
# Fizzy
This file provides guidance to AI coding agents working with this repository.
## What is Fizzy?
Fizzy is a collaborative project management and issue tracking application built by 37signals/Basecamp. It's a kanban-style tool for teams to create and manage cards (tasks/issues) across boards, organize work into columns representing workflow stages, and collaborate via comments, mentions, and assignments.
## Development Commands
### Setup and Server
```bash
bin/setup # Initial setup (installs gems, creates DB, loads schema)
bin/dev # Start development server (runs on port 3006)
```
Development URL: http://fizzy.localhost:3006
Login with: david@37signals.com (development fixtures), password will appear in the browser console
### Testing
```bash
bin/rails test # Run unit tests (fast)
bin/rails test test/path/file_test.rb # Run single test file
bin/rails test:system # Run system tests (Capybara + Selenium)
bin/ci # Run full CI suite (style, security, tests)
# For parallel test execution issues, use:
PARALLEL_WORKERS=1 bin/rails test
```
CI pipeline (`bin/ci`) runs:
1. Rubocop (style)
2. Bundler audit (gem security)
3. Importmap audit
4. Brakeman (security scan)
5. Application tests
6. System tests
### Database
```bash
bin/rails db:fixtures:load # Load fixture data
bin/rails db:migrate # Run migrations
bin/rails db:reset # Drop, create, and load schema
```
### Other Utilities
```bash
bin/rails dev:email # Toggle letter_opener for email preview
bin/jobs # Manage Solid Queue jobs
bin/kamal deploy # Deploy (requires 1Password CLI for secrets)
```
## Architecture Overview
### Multi-Tenancy (URL-Based)
Fizzy uses **URL path-based multi-tenancy**:
- Each Account (tenant) has a unique `external_account_id` (7+ digits)
- URLs are prefixed: `/{account_id}/boards/...`
- Middleware (`AccountSlug::Extractor`) extracts the account ID from the URL and sets `Current.account`
- The slug is moved from `PATH_INFO` to `SCRIPT_NAME`, making Rails think it's "mounted" at that path
- All models include `account_id` for data isolation
- Background jobs automatically serialize and restore account context
**Key insight**: This architecture allows multi-tenancy without subdomains or separate databases, making local development and testing simpler.
### Authentication & Authorization
**Passwordless magic link authentication**:
- Global `Identity` (email-based) can have `Users` in multiple Accounts
- Users belong to an Account and have roles: admin, member, system
- Sessions managed via signed cookies
- Board-level access control via `Access` records
### Core Domain Models
**Account** → The tenant/organization
- Has users, boards, cards, tags, webhooks
- Has entropy configuration for auto-postponement
**Identity** → Global user (email)
- Can have Users in multiple Accounts
- Session management tied to Identity
**User** → Account membership
- Belongs to Account and Identity
- Has role (admin/member/system)
- Board access via explicit `Access` records
**Board** → Primary organizational unit
- Has columns for workflow stages
- Can be "all access" or selective
- Can be published publicly with shareable key
**Card** → Main work item (task/issue)
- Sequential number within each Account
- Rich text description and attachments
- Lifecycle: triage → columns → closed/not_now
- Automatically postpones after inactivity ("entropy")
**Event** → Records all significant actions
- Polymorphic association to changed object
- Drives activity timeline, notifications, webhooks
- Has JSON `particulars` for action-specific data
### Entropy System
Cards automatically "postpone" (move to "not now") after inactivity:
- Account-level default entropy period
- Board-level entropy override
- Prevents endless todo lists from accumulating
- Configurable via Account/Board settings
### UUID Primary Keys
All tables use UUIDs (UUIDv7 format, base36-encoded as 25-char strings):
- Custom fixture UUID generation maintains deterministic ordering for tests
- Fixtures are always "older" than runtime records
- `.first`/`.last` work correctly in tests
### Background Jobs (Solid Queue)
Database-backed job queue (no Redis):
- Custom `FizzyActiveJobExtensions` prepended to ActiveJob
- Jobs automatically capture/restore `Current.account`
- Mission Control::Jobs for monitoring
Key recurring tasks (via `config/recurring.yml`):
- Deliver bundled notifications (every 30 min)
- Auto-postpone stale cards (hourly)
- Cleanup jobs for expired links, deliveries
### Sharded Full-Text Search
16-shard MySQL full-text search instead of Elasticsearch:
- Shards determined by account ID hash (CRC32)
- Search records denormalized for performance
- Models in `app/models/search/`
## Production Observability
Grafana MCP tools provide access to production metrics and logs for performance analysis.
### Datasources
| Name | UID | Use |
|------|-----|-----|
| Thanos (Prometheus) | `PC96415006F908B67` | Metrics, latencies |
| Loki | `e38bdfea-097e-47fa-a7ab-774fd2487741` | Application logs |
### Key Metrics
- `rails_request_duration_seconds_bucket:rate1m:sum_by_app:quantiles{app="fizzy"}` - Request latency percentiles
- `rails_request_total:rate1m:sum_by_controller_action{app="fizzy"}` - Request rates by endpoint
- `fizzy_replica_wait_seconds` - Database replica consistency wait times
### Loki Log Labels
Query: `{service_namespace="fizzy", deployment_environment_name="production", service_name="rails"}`
Useful fields: `event_duration_ms`, `performance_time_db_ms`, `performance_time_cpu_ms`, `rails_endpoint`, `rails_controller`
### Instrumentation
Yabeda-based metrics exported at `:9394/metrics`. Config in `config/initializers/yabeda.rb`.
### Chrome MCP (Local Dev)
URL: `http://fizzy.localhost:3006`
Login: david@37signals.com (passwordless magic link auth - check rails console for link)
Use Chrome MCP tools to interact with the running dev app for UI testing and debugging.
### Sentry Error Tracking
Organization: `basecamp` | Project: `fizzy` | Region: `https://us.sentry.io`
Use Sentry MCP tools to investigate production errors:
- `search_issues` - Find grouped issues by natural language query
- `get_issue_details` - Get full stacktrace and context for a specific issue
- `analyze_issue_with_seer` - AI-powered root cause analysis with code fix suggestions
## Coding style
Please read the separate file `STYLE.md` for some guidance on coding style.
+1 -142
View File
@@ -1,142 +1 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is Fizzy?
Fizzy is a collaborative project management and issue tracking application built by 37signals/Basecamp. It's a kanban-style tool for teams to create and manage cards (tasks/issues) across boards, organize work into columns representing workflow stages, and collaborate via comments, mentions, and assignments.
## Development Commands
### Setup and Server
```bash
bin/setup # Initial setup (installs gems, creates DB, loads schema)
bin/dev # Start development server (runs on port 3006)
```
Development URL: http://fizzy.localhost:3006
Login with: david@37signals.com (development fixtures), password will appear in the browser console
### Testing
```bash
bin/rails test # Run unit tests (fast)
bin/rails test test/path/file_test.rb # Run single test file
bin/rails test:system # Run system tests (Capybara + Selenium)
bin/ci # Run full CI suite (style, security, tests)
# For parallel test execution issues, use:
PARALLEL_WORKERS=1 bin/rails test
```
CI pipeline (`bin/ci`) runs:
1. Rubocop (style)
2. Bundler audit (gem security)
3. Importmap audit
4. Brakeman (security scan)
5. Application tests
6. System tests
### Database
```bash
bin/rails db:fixtures:load # Load fixture data
bin/rails db:migrate # Run migrations
bin/rails db:reset # Drop, create, and load schema
```
### Other Utilities
```bash
bin/rails dev:email # Toggle letter_opener for email preview
bin/jobs # Manage Solid Queue jobs
bin/kamal deploy # Deploy (requires 1Password CLI for secrets)
```
## Architecture Overview
### Multi-Tenancy (URL-Based)
Fizzy uses **URL path-based multi-tenancy**:
- Each Account (tenant) has a unique `external_account_id` (7+ digits)
- URLs are prefixed: `/{account_id}/boards/...`
- Middleware (`AccountSlug::Extractor`) extracts the account ID from the URL and sets `Current.account`
- The slug is moved from `PATH_INFO` to `SCRIPT_NAME`, making Rails think it's "mounted" at that path
- All models include `account_id` for data isolation
- Background jobs automatically serialize and restore account context
**Key insight**: This architecture allows multi-tenancy without subdomains or separate databases, making local development and testing simpler.
### Authentication & Authorization
**Passwordless magic link authentication**:
- Global `Identity` (email-based) can have `Users` in multiple Accounts
- Users belong to an Account and have roles: admin, member, system
- Sessions managed via signed cookies
- Board-level access control via `Access` records
### Core Domain Models
**Account** → The tenant/organization
- Has users, boards, cards, tags, webhooks
- Has entropy configuration for auto-postponement
**Identity** → Global user (email)
- Can have Users in multiple Accounts
- Session management tied to Identity
**User** → Account membership
- Belongs to Account and Identity
- Has role (admin/member/system)
- Board access via explicit `Access` records
**Board** → Primary organizational unit
- Has columns for workflow stages
- Can be "all access" or selective
- Can be published publicly with shareable key
**Card** → Main work item (task/issue)
- Sequential number within each Account
- Rich text description and attachments
- Lifecycle: triage → columns → closed/not_now
- Automatically postpones after inactivity ("entropy")
**Event** → Records all significant actions
- Polymorphic association to changed object
- Drives activity timeline, notifications, webhooks
- Has JSON `particulars` for action-specific data
### Entropy System
Cards automatically "postpone" (move to "not now") after inactivity:
- Account-level default entropy period
- Board-level entropy override
- Prevents endless todo lists from accumulating
- Configurable via Account/Board settings
### UUID Primary Keys
All tables use UUIDs (UUIDv7 format, base36-encoded as 25-char strings):
- Custom fixture UUID generation maintains deterministic ordering for tests
- Fixtures are always "older" than runtime records
- `.first`/`.last` work correctly in tests
### Background Jobs (Solid Queue)
Database-backed job queue (no Redis):
- Custom `FizzyActiveJobExtensions` prepended to ActiveJob
- Jobs automatically capture/restore `Current.account`
- Mission Control::Jobs for monitoring
Key recurring tasks (via `config/recurring.yml`):
- Deliver bundled notifications (every 30 min)
- Auto-postpone stale cards (hourly)
- Cleanup jobs for expired links, deliveries
### Sharded Full-Text Search
16-shard MySQL full-text search instead of Elasticsearch:
- Shards determined by account ID hash (CRC32)
- Search records denormalized for performance
- Models in `app/models/search/`
## Coding style
Please read the separate file `STYLE.md` for some guidance on coding style.
@AGENTS.md
+2 -1
View File
@@ -34,6 +34,7 @@ gem "aws-sdk-s3", require: false
gem "web-push"
gem "net-http-persistent"
gem "mittens"
gem "useragent", bc: "useragent"
# Telemetry, logging, and operations
gem "mission_control-jobs"
@@ -49,7 +50,7 @@ gem "yabeda-prometheus-mmap"
gem "yabeda-puma-plugin"
gem "yabeda-rails"
gem "webrick" # required for yabeda-prometheus metrics server
gem "prometheus-client-mmap", "~> 1.1"
gem "prometheus-client-mmap", "~> 1.3"
gem "autotuner"
gem "benchmark" # indirect dependency, being removed from Ruby 3.5 stdlib so here to quash warnings
+55 -49
View File
@@ -16,6 +16,12 @@ GIT
json
rails (>= 6.0.0)
GIT
remote: https://github.com/basecamp/useragent
revision: 433ca320a42db1266c4b89df74d0abdb9a880c5e
specs:
useragent (0.16.11)
GIT
remote: https://github.com/basecamp/yabeda-activejob.git
revision: 684973f77ff01d8b3dd75874538fae55961e15e6
@@ -27,7 +33,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 077c3ad60db4c43cccb8f4637b53b8d8eb7a3c19
revision: b22cb0c0cb39b103d816a66d560991acf0a57163
branch: main
specs:
actioncable (8.2.0.alpha)
@@ -147,15 +153,15 @@ GEM
activemodel (>= 7.0)
activemodel-serializers-xml (~> 1.0)
activesupport (>= 7.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
anyway_config (2.7.2)
ruby-next-core (~> 1.0)
ast (2.4.3)
autotuner (1.0.2)
autotuner (1.1.0)
aws-eventstream (1.4.0)
aws-partitions (1.1175.0)
aws-sdk-core (3.234.0)
aws-partitions (1.1187.0)
aws-sdk-core (3.239.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -163,10 +169,10 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.115.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.201.0)
aws-sdk-s3 (1.205.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -178,9 +184,9 @@ GEM
benchmark (0.5.0)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.18.6)
bootsnap (1.19.0)
msgpack (~> 1.2)
brakeman (7.1.0)
brakeman (7.1.1)
racc
builder (3.3.0)
bundler-audit (0.9.2)
@@ -199,7 +205,7 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
connection_pool (2.5.5)
crack (1.0.1)
bigdecimal
rexml
@@ -290,7 +296,7 @@ GEM
logger
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.26.1)
minitest (5.26.2)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
actionpack (>= 7.1)
@@ -302,7 +308,7 @@ GEM
stimulus-rails
turbo-rails
mittens (0.3.0)
mocha (2.7.1)
mocha (2.8.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
net-http-persistent (4.0.6)
@@ -333,10 +339,10 @@ GEM
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-musl)
racc (~> 1.4)
openssl (3.3.1)
openssl (3.3.2)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.9.0)
parser (3.3.10.0)
ast (~> 2.4.1)
racc
platform_agent (1.0.1)
@@ -346,31 +352,31 @@ GEM
prettyprint
prettyprint (0.2.0)
prism (1.6.0)
prometheus-client-mmap (1.2.10)
prometheus-client-mmap (1.3.0)
base64
bigdecimal
logger
rb_sys (~> 0.9.109)
prometheus-client-mmap (1.2.10-arm64-darwin)
rb_sys (~> 0.9.117)
prometheus-client-mmap (1.3.0-arm64-darwin)
base64
bigdecimal
logger
rb_sys (~> 0.9.109)
prometheus-client-mmap (1.2.10-x86_64-darwin)
rb_sys (~> 0.9.117)
prometheus-client-mmap (1.3.0-x86_64-darwin)
base64
bigdecimal
logger
rb_sys (~> 0.9.109)
prometheus-client-mmap (1.2.10-x86_64-linux-gnu)
rb_sys (~> 0.9.117)
prometheus-client-mmap (1.3.0-x86_64-linux-gnu)
base64
bigdecimal
logger
rb_sys (~> 0.9.109)
prometheus-client-mmap (1.2.10-x86_64-linux-musl)
rb_sys (~> 0.9.117)
prometheus-client-mmap (1.3.0-x86_64-linux-musl)
base64
bigdecimal
logger
rb_sys (~> 0.9.109)
rb_sys (~> 0.9.117)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -378,7 +384,7 @@ GEM
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
public_suffix (7.0.0)
puma (7.1.0)
nio4r (~> 2.0)
raabro (1.4.0)
@@ -405,7 +411,7 @@ GEM
rake-compiler-dock (1.9.1)
rb_sys (0.9.117)
rake-compiler-dock (= 1.9.1)
rdoc (6.15.1)
rdoc (6.16.0)
erb
psych (>= 4.0.0)
tsort
@@ -418,8 +424,8 @@ GEM
rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rubocop (1.81.1)
rqrcode_core (2.0.1)
rubocop (1.81.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -430,14 +436,14 @@ GEM
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.47.1)
rubocop-ast (1.48.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.26.0)
rubocop-performance (1.26.1)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rails (2.33.4)
rubocop-ast (>= 1.47.1, < 2.0)
rubocop-rails (2.34.1)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
@@ -453,7 +459,7 @@ GEM
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (3.2.1)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.38.0)
base64 (~> 0.2)
@@ -461,10 +467,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (6.0.0)
sentry-rails (6.1.2)
railties (>= 5.2.0)
sentry-ruby (~> 6.0.0)
sentry-ruby (6.0.0)
sentry-ruby (~> 6.1.2)
sentry-ruby (6.1.2)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sniffer (0.5.0)
@@ -475,23 +481,23 @@ GEM
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_cache (1.0.8)
solid_cache (1.0.10)
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.2.2)
solid_queue (1.2.4)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.7.4)
sqlite3 (2.8.0)
mini_portile2 (~> 2.8.0)
sqlite3 (2.7.4-arm64-darwin)
sqlite3 (2.7.4-x86_64-darwin)
sqlite3 (2.7.4-x86_64-linux-gnu)
sqlite3 (2.7.4-x86_64-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)
base64
logger
@@ -519,7 +525,6 @@ GEM
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
uri (1.1.1)
useragent (0.16.11)
vcr (6.3.1)
base64
web-console (4.2.1)
@@ -530,7 +535,7 @@ GEM
web-push (3.0.2)
jwt (~> 3.0)
openssl (~> 3.0)
webmock (3.26.0)
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -560,7 +565,7 @@ GEM
yabeda-prometheus-mmap (0.4.0)
prometheus-client-mmap
yabeda (~> 0.10)
yabeda-puma-plugin (0.8.0)
yabeda-puma-plugin (0.9.0)
json
puma
yabeda (~> 0.5)
@@ -604,7 +609,7 @@ DEPENDENCIES
mocha
net-http-persistent
platform_agent
prometheus-client-mmap (~> 1.1)
prometheus-client-mmap (~> 1.3)
propshaft
puma (>= 5.0)
queenbee!
@@ -626,6 +631,7 @@ DEPENDENCIES
thruster
trilogy (~> 2.9)
turbo-rails
useragent!
vcr
web-console
web-push
Binary file not shown.

After

Width:  |  Height:  |  Size: 932 KiB

+2 -2
View File
@@ -74,11 +74,11 @@
--z-events-column-header: 1;
--z-events-day-header: 3;
--z-popup: 10;
--z-bar: 20;
--z-tray: 21;
--z-nav: 30;
--z-flash: 40;
--z-tooltip: 50;
--z-bar: 60;
--z-tray: 61;
/* OKLCH colors: Fixed */
--lch-black: 0% 0 0;
+4 -2
View File
@@ -11,7 +11,9 @@
inset: auto 0 0 0;
max-block-size: 100%;
padding-block: var(--block-space) calc(var(--block-space) + env(safe-area-inset-bottom));
padding-inline: calc(var(--tray-size) + calc(var(--inline-space) * 3));
padding-inline:
calc(var(--tray-size) + calc(var(--inline-space) * 3) + env(safe-area-inset-left))
calc(var(--tray-size) + calc(var(--inline-space) * 3) + env(safe-area-inset-right));
place-content: center;
position: fixed;
view-transition-name: bar;
@@ -46,7 +48,7 @@
inline-size: 100vw;
inset: auto 0 0 0;
max-inline-size: 100vw;
margin-block-end: calc(var(--footer-height) - 0.3rem);
margin-block-end: calc(var(--footer-height) - 0.3rem + env(safe-area-inset-bottom));
position: fixed;
z-index: -1;
+4
View File
@@ -54,6 +54,10 @@
::selection {
background-color: var(--color-selected);
@media (prefers-color-scheme: dark) {
background-color: var(--color-selected-dark);
}
}
:where(ul, ol):where([role="list"]) {
+8
View File
@@ -109,12 +109,19 @@
aspect-ratio: 1;
padding: 0.5em;
kbd,
span:last-of-type {
display: none;
}
}
}
@media (min-width: 640px) {
.btn--circle-mobile .icon--mobile-only {
display: none !important;
}
}
.btn--negative {
--btn-background: var(--color-negative);
--btn-border-color: var(--color-negative);
@@ -219,6 +226,7 @@
}
}
/* Button groups
/* ------------------------------------------------------------------------ */
+25 -4
View File
@@ -67,6 +67,7 @@
--column-color: color-mix(in srgb, var(--card-color) 15%, var(--color-canvas));
inline-size: var(--column-width-expanded);
outline: none;
position: relative;
scroll-snap-align: start;
@@ -111,7 +112,6 @@
}
}
@media (any-hover: hover) {
.card:has(.card__background img:not([src=""])):hover .card__background img:not([src=""]) {
filter: blur(3px) brightness(1.2);
@@ -172,6 +172,15 @@
.cards--grid & {
display: contents;
}
[aria-selected] & .card[aria-selected] {
outline: var(--focus-ring-size) solid var(--color-selected-dark);
outline-offset: var(--focus-ring-offset);
@media (prefers-color-scheme: dark) {
outline-color: oklch(var(--lch-blue-medium));
}
}
}
.cards__new-column {
@@ -429,9 +438,7 @@
/* Cover bubbles and cards on small viewports */
@media (max-width: 639px) {
&:not(.cards--closed &) {
margin-inline: calc(-1 * var(--cards-gap) - var(--main-padding));
}
margin-inline: calc(-1 * var(--cards-gap) - var(--main-padding));
}
&, .cards__filter {
@@ -650,6 +657,7 @@
/* On Deck (Not Now)
/* ------------------------------------------------------------------------ */
.cards--closed,
.cards--on-deck {
--card-color: var(--color-ink-light) !important;
@@ -662,10 +670,18 @@
}
}
/* Closed (Done)
/* ------------------------------------------------------------------------ */
.cards--closed {
}
/* Doing
/* -------------------------------------------------------------------------- */
/* Surface a mini bubble if there are cards with bubbles inside */
.cards--considering:has(.bubble:not([hidden])),
.cards--doing.is-collapsed:has(.bubble:not([hidden])) {
.cards__transition-container {
--bubble-color: var(--card-color, oklch(var(--lch-blue-medium)));
@@ -685,6 +701,11 @@
translate: 20% -20%;
z-index: 1;
}
.cards--considering &:before {
inset: 0 auto auto 0;
translate: 100% 75%;
}
}
}
+19 -3
View File
@@ -1,4 +1,8 @@
@layer components {
#header:has(.filters) {
position: relative;
}
.filters {
align-items: center;
display: flex;
@@ -13,10 +17,10 @@
--btn-border-color: var(--color-ink-medium);
--input-background: var(--color-canvas);
}
}
#header:has(.filters) {
position: relative;
&:has(.filter-toggle:hover) {
z-index: calc(var(--z-nav) + 1);
}
}
.filter {
@@ -170,5 +174,17 @@
.filters--has-filters-set .filters__manage {
display: flex;
}
.filters__show-when-expanded {
.filters:not(.filters--expanded) & {
display: none;
}
}
.filters__show-when-collapsed {
.filters--expanded & {
display: none;
}
}
}
+3 -1
View File
@@ -12,7 +12,9 @@
inline-size: 100dvw;
margin-inline: auto;
max-inline-size: 100dvw;
padding-inline: var(--main-padding);
padding-inline:
calc(var(--main-padding) + env(safe-area-inset-left))
calc(var(--main-padding) + env(safe-area-inset-right));
text-align: center;
}
+9
View File
@@ -75,10 +75,15 @@
0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);
grid-template-rows: auto 1fr auto;
gap: var(--nav-section-gap);
max-block-size: calc(100vh - var(--block-space) - var(--footer-height));
overflow: hidden;
padding-block-end: 0;
scrollbar-gutter: stable both-edges;
z-index: var(--z-nav);
@media (max-height: 668px) {
max-block-size: calc(100vh - var(--block-space));
}
}
.nav__scroll-container {
@@ -209,5 +214,9 @@
padding: 1.5ch;
text-align: center;
z-index: 1;
@media (max-height: 668px) {
font-size: var(--text-x-small);
}
}
}
+2 -1
View File
@@ -9,8 +9,9 @@
--popup-display: flex;
inset: 0 auto auto 50%;
min-inline-size: min(25ch, calc(100vw - (var(--panel-padding) * 2)));
max-block-size: 70dvh;
max-inline-size: min(55ch, calc(100vw - (var(--panel-padding) * 2)));
min-inline-size: min(25ch, calc(100vw - (var(--panel-padding) * 2)));
overflow: auto;
position: absolute;
transform: translateX(-50%);
+2 -19
View File
@@ -125,13 +125,11 @@
.card--new,
.cards__decoration,
.card-columns:before,
.cards--considering:before,
.cards--closed:before {
.cards--considering:before {
display: none;
}
.card-columns,
.cards--closed {
.card-columns {
border-block-start: var(--border-light);
margin-block-end: 1ch;
min-block-size: unset;
@@ -156,21 +154,6 @@
}
}
.cards--closed {
.card:nth-of-type(7) {
margin-block: 0;
}
.card:nth-of-type(n+7) {
border-inline: none;
max-inline-size: none;
}
.card:nth-of-type(n+8) {
margin-block-start: calc(-1 * var(--cards-gap) - 1px);
}
}
/* Card perma
/* ------------------------------------------------------------------------ */
+6 -2
View File
@@ -74,10 +74,14 @@
gap: 0.25ch;
max-inline-size: 100%;
opacity: 1;
padding: 0.1em 0.12em;
postion: relative;
padding: 0.1em 0.36em 0.1em 0.12em;
position: relative;
transition: opacity 100ms ease-in-out, transform 150ms ease-in-out;
&:has(span.txt-small.txt-medium) { /* emoji only reactions */
padding: 0.1em 0.12em;
}
.btn {
font-size: 0.6em;
inline-size: auto;
+10 -5
View File
@@ -28,10 +28,19 @@
}
}
:where(b, strong) {
:where(b, strong, .lexxy-content__bold) {
font-weight: 700;
}
:where(i, em, .lexxy-content__italic) {
font-style: italic;
}
:where(s, .lexxy-content__strikethrough) {
text-decoration: line-through;
}
:where(p, blockquote) {
letter-spacing: -0.005ch;
}
@@ -75,10 +84,6 @@
padding-block: var(--block-margin);
}
.lexxy-content__strikethrough {
text-decoration: line-through;
}
/* Code */
:where(code, pre) {
background-color: var(--color-canvas);
+2 -18
View File
@@ -50,35 +50,19 @@ summary {
.search__reset {
--btn-background: var(--color-terminal-bg);
--btn-size: 1.25lh;
@media (min-width: 800px) {
.search__input:placeholder-shown ~ & {
opacity: 0;
}
}
--btn-size: 1.5lh;
}
/* Content
/* ------------------------------------------------------------------------ */
.search__header {
align-items: center;
display: flex;
inset: 0 0 auto 0;
justify-content: flex-end;
padding: var(--block-space-half) var(--block-space);
pointer-events: none;
position: absolute;
}
.search__list {
display: grid;
gap: var(--block-space);
list-style: none;
margin: 0 auto;
max-inline-size: 80ch;
max-inline-size: min(80ch, 100%);
padding: 0;
text-align: start;
}
+2 -1
View File
@@ -33,7 +33,7 @@
transform-origin: center;
transition: 150ms transform ease-in-out;
}
&:checked::before {
transform: scale(1) rotate(10deg);
}
@@ -73,6 +73,7 @@
@supports (field-sizing: content) {
field-sizing: content;
max-inline-size: 100%;
min-inline-size: 15ch;
}
}
+1 -1
View File
@@ -10,7 +10,7 @@
font-size: var(--text-x-small);
font-weight: normal;
inset: -1ch auto auto 50%;
max-inline-size: 50ch;
max-inline-size: min(50ch, calc(100vw - (var(--inline-space) * 2)));
opacity: 0;
padding: 0.25ch 1ch;
transition: var(--tooltip-duration) ease-out allow-discrete;
+2 -1
View File
@@ -2,9 +2,10 @@ class ApplicationController < ActionController::Base
include Authentication
include Authorization
include CurrentRequest, CurrentTimezone, SetPlatform
include RequestForgeryProtection
include TurboFlash, ViewTransitions
include Saas
include RoutingHeaders, WriterAffinity
include RoutingHeaders
etag { "v1" }
stale_when_importmap_changes
@@ -2,7 +2,8 @@ class Cards::AssignmentsController < ApplicationController
include CardScoped
def new
@users = @board.users.active.alphabetically
@assigned_to = @card.assignees.active.alphabetically.where.not(id: Current.user)
@users = @board.users.active.alphabetically.where.not(id: @card.assignees).where.not(id: Current.user)
fresh_when etag: [ @users, @card.assignees ]
end
@@ -1,8 +1,6 @@
class Cards::ReadingsController < ApplicationController
include CardScoped
skip_writer_affinity
def create
@notifications = @card.read_by(Current.user)
record_board_access
+2 -1
View File
@@ -2,7 +2,8 @@ class Cards::TaggingsController < ApplicationController
include CardScoped
def new
@tags = Current.account.tags.all.alphabetically
@tagged_with = @card.tags.alphabetically
@tags = Current.account.tags.all.alphabetically.where.not(id: @tagged_with)
fresh_when etag: [ @tags, @card.tags ]
end
-1
View File
@@ -22,7 +22,6 @@ class CardsController < ApplicationController
def update
@card.update! card_params
redirect_to @card
end
def destroy
@@ -0,0 +1,50 @@
module RequestForgeryProtection
extend ActiveSupport::Concern
included do
after_action :append_set_fetch_site_to_vary_header
end
private
def append_set_fetch_site_to_vary_header
vary_header = response.headers["Vary"].to_s.split(",").map(&:strip).reject(&:blank?)
response.headers["Vary"] = (vary_header + [ "Sec-Fetch-Site" ]).join(",")
end
def verified_request?
return true if request.get? || request.head? || !protect_against_forgery?
origin = valid_request_origin?
token = any_authenticity_token_valid?
sec_fetch_site = safe_fetch_site?
report_on_forgery_protection_results(origin:, token:, sec_fetch_site:)
origin && token
end
SAFE_FETCH_SITES = %w[ same-origin same-site ]
def safe_fetch_site?
SAFE_FETCH_SITES.include?(safe_fetch_site_value)
end
def safe_fetch_site_value
request.headers["Sec-Fetch-Site"].to_s.downcase
end
def report_on_forgery_protection_results(origin:, token:, sec_fetch_site:)
results = { origin:, token:, sec_fetch_site: }
unless results.values.all?
info = results.transform_values { it ? "pass" : "fail" }
info[:origin] += " (#{request.origin})"
info[:sec_fetch_site] += " (#{safe_fetch_site_value})"
Rails.logger.info "CSRF protection check: " + info.map { it.join(" ") }.join(", ")
if (origin && token) != sec_fetch_site
Sentry.capture_message "CSRF protection mismatch", level: :info, extra: { info: info }
end
end
end
end
@@ -1,14 +0,0 @@
module WriterAffinity
extend ActiveSupport::Concern
class_methods do
def skip_writer_affinity(**)
before_action :set_writer_affinity_opt_out_header, **
end
end
private
def set_writer_affinity_opt_out_header
response.headers["X-Writer-Affinity"] = "false"
end
end
+3 -1
View File
@@ -1,6 +1,8 @@
class NotificationsController < ApplicationController
MAX_UNREAD_NOTIFICATIONS = 500
def index
@unread = Current.user.notifications.unread.ordered.preloaded unless current_page_param
@unread = Current.user.notifications.unread.ordered.preloaded.limit(MAX_UNREAD_NOTIFICATIONS) unless current_page_param
set_page_and_extract_portion_from Current.user.notifications.read.ordered.preloaded
respond_to do |format|
+10 -6
View File
@@ -7,8 +7,12 @@ class Users::AvatarsController < ApplicationController
before_action :ensure_permission_to_administer_user, only: :destroy
def show
if stale? @user, cache_control: { max_age: (30.minutes unless Current.user == @user), stale_while_revalidate: 1.week }.compact
render_avatar_or_initials
if @user.system?
redirect_to view_context.image_path("system_user.png")
elsif @user.avatar.attached?
redirect_to rails_blob_url(@user.avatar.variant(:thumb), disposition: "inline")
elsif stale? @user, cache_control: cache_control
render_initials
end
end
@@ -26,11 +30,11 @@ class Users::AvatarsController < ApplicationController
head :forbidden unless Current.user.can_change?(@user)
end
def render_avatar_or_initials
if @user.avatar.attached?
send_blob_stream @user.avatar
def cache_control
if @user == Current.user
{}
else
render_initials
{ max_age: 30.minutes, stale_while_revalidate: 1.week }
end
end
+30 -1
View File
@@ -9,9 +9,38 @@ module ColumnsHelper
data: { turbo_frame: "_top" }
end
def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, data: {}, **properties, &block)
classes = token_list("cards", properties.delete(:class), "is-collapsed": collapsed)
data = {
drag_and_drop_target: "container",
navigable_list_target: "item",
column_name: name,
drag_and_drop_url: drop_url
}.merge(data)
data[:action] = token_list(
"turbo:before-morph-attribute->collapsible-columns#preventToggle",
"focus->navigable-list#select",
data.delete(:action)
)
tag.section(id: id, class: classes, tabindex: "0", "aria-selected": selected, data: data, **properties) do
tag.div(class: "cards__transition-container", data: {
controller: "navigable-list",
navigable_list_supports_horizontal_navigation_value: "false",
navigable_list_prevent_handled_keys_value: "true",
navigable_list_auto_select_value: "false",
navigable_list_actionable_items_value: "true",
navigable_list_only_act_on_focused_items_value: "true",
action: "keydown->navigable-list#navigate"
}, &block)
end
end
def column_frame_tag(id, src: nil, data: {}, **options, &block)
data = data.reverse_merge \
"drag-and-drop-refresh": true,
drag_and_drop_refresh: true,
controller: "frame",
action: "turbo:before-frame-render->frame#morphRender turbo:before-morph-element->frame#morphReload"
options[:refresh] = :morph if src.present?
+1 -1
View File
@@ -9,7 +9,7 @@ module EventsHelper
"comment"
when "card_title_changed"
"rename"
when "card_board_changed", "card_triaged", "card_postponed"
when "card_board_changed", "card_triaged", "card_postponed", "card_auto_postponed"
"move"
else
"person"
+1 -1
View File
@@ -10,7 +10,7 @@ module WebhooksHelper
card_closed: "Card moved to “Done”",
card_reopened: "Card reopened",
card_postponed: "Card moved to “Not Now”",
card_auto_postponed: "Card auto-closed as “Not Now”",
card_auto_postponed: "Card moved to “Not Now” due to inactivity",
card_sent_back_to_triage: "Card moved back to “Maybe?”"
}.with_indifferent_access.freeze
+13 -1
View File
@@ -1,5 +1,6 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
import { nextFrame } from "helpers/timing_helpers";
export default class extends Controller {
static targets = [ "turboFrame", "search", "searchInput", "form", "buttonsContainer" ]
@@ -21,6 +22,15 @@ export default class extends Controller {
this.#hideItem(this.searchTarget)
}
clearInput() {
if (this.searchInputTarget.value) {
this.searchInputTarget.value = ""
this.searchInputTarget.focus()
} else {
this.reset()
}
}
showModalAndSubmit(event) {
this.showModal()
this.formTarget.requestSubmit()
@@ -50,11 +60,13 @@ export default class extends Controller {
this.turboFrameTarget.innerHtml = ""
}
#showItem(element) {
async #showItem(element) {
element.removeAttribute("hidden")
const autofocusElement = element.querySelector("[autofocus]")
autofocusElement?.focus()
await nextFrame()
autofocusElement?.select()
}
@@ -40,6 +40,13 @@ export default class extends Controller {
await this.#restoreColumnsDisablingTransitions()
}
focusOnColumn({ target }) {
if (this.#isCollapsed(target)) {
this.#collapseAllExcept(target)
this.#expand(target)
}
}
async #restoreColumnsDisablingTransitions() {
this.#disableTransitions()
this.#restoreColumns()
@@ -1,6 +1,5 @@
import { Controller } from "@hotwired/stimulus"
import { orient } from "helpers/orientation_helpers"
import { limitHeightToViewport } from "helpers/sizing_helpers"
export default class extends Controller {
static targets = [ "dialog" ]
@@ -23,8 +22,6 @@ export default class extends Controller {
orient(this.dialogTarget)
}
limitHeightToViewport(this.dialogTarget, this.sizingValue)
this.loadLazyFrames()
this.dialogTarget.setAttribute("aria-hidden", "false")
this.dispatch("show")
@@ -43,7 +40,6 @@ export default class extends Controller {
this.dialogTarget.setAttribute("aria-hidden", "true")
this.dialogTarget.blur()
orient(this.dialogTarget, false)
limitHeightToViewport(this.dialogTarget, false)
this.dispatch("close")
}
@@ -8,11 +8,22 @@ export default class extends Controller {
selectionAttribute: { type: String, default: "aria-selected" },
focusOnSelection: { type: Boolean, default: true },
actionableItems: { type: Boolean, default: false },
reverseNavigation: { type: Boolean, default: false }
reverseNavigation: { type: Boolean, default: false },
supportsHorizontalNavigation: { type: Boolean, default: true },
supportsVerticalNavigation: { type: Boolean, default: true },
hasNestedNavigation: { type: Boolean, default: false },
preventHandledKeys: { type: Boolean, default: false },
autoSelect: { type: Boolean, default: true },
autoScroll: { type: Boolean, default: true },
onlyActOnFocusedItems: { type: Boolean, default: false }
}
connect() {
this.reset()
if (this.autoSelectValue) {
this.reset()
} else {
this.#activateManualSelection()
}
}
// Actions
@@ -27,10 +38,11 @@ export default class extends Controller {
navigate(event) {
this.#keyHandlers[event.key]?.call(this, event)
this.#relayNavigationToParentNavigableList(event)
}
select({ target }) {
this.#setCurrentFrom(target)
this.selectItem(target, true)
}
selectCurrentOrReset(event) {
@@ -49,12 +61,109 @@ export default class extends Controller {
this.#setCurrentFrom(this.#visibleItems[this.#visibleItems.length - 1])
}
deselectWhenClickingOutside(event) {
if (this.element.contains(event.target)) {
return
}
this.#clearSelection()
}
// Public
async selectItem(item, skipFocus = false) {
await this.#selectCurrentElementInParent()
this.#clearSelection()
item.setAttribute(this.selectionAttributeValue, "true")
this.currentItem = item
this.#refreshActiveDescendant()
await nextFrame()
if (this.autoScrollValue) { this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" }) }
if (this.hasNestedNavigationValue) { this.#activateNestedNavigableList() }
if (!skipFocus && this.focusOnSelectionValue) { this.currentItem.focus({ preventScroll: !this.autoScrollValue }) }
}
isSelected(item) {
return item === this.currentItem
}
// Private
get #visibleItems() {
return this.itemTargets.filter(item => {
return item.checkVisibility() && !item.hidden
})
async #setCurrentFrom(element) {
const selectedItem = this.#visibleItems.find(item => item.contains(element))
if (selectedItem) {
await this.selectItem(selectedItem)
}
}
get #parentNavigableListController() {
const parentNavigableList = this.element.parentElement?.closest("[data-controller~='navigable-list']")
if (parentNavigableList) {
return this.application.getControllerForElementAndIdentifier(parentNavigableList, "navigable-list")
}
return null
}
async #selectCurrentElementInParent() {
const parentController = this.#parentNavigableListController
if (parentController) {
const parentItem = this.element.closest("[data-navigable-list-target~='item']")
const isAlreadySelected = parentController.isSelected(parentItem)
if (!isAlreadySelected) {
await parentController.selectItem(parentItem, true)
}
}
}
#clearSelection() {
for (const item of this.itemTargets) {
item.removeAttribute(this.selectionAttributeValue)
}
}
#refreshActiveDescendant() {
const id = this.currentItem?.getAttribute("id")
if (this.hasInputTarget && id) {
this.inputTarget.setAttribute("aria-activedescendant", id)
}
}
#activateNestedNavigableList() {
const nestedController = this.#nestedNavigableListController()
if (nestedController) {
nestedController.selectCurrentOrReset()
return true
}
return false
}
#nestedNavigableListController() {
const nestedElement = this.currentItem?.querySelector('[data-controller~="navigable-list"]')
if (nestedElement) {
return this.application.getControllerForElementAndIdentifier(nestedElement, "navigable-list")
}
return null
}
#activateManualSelection() {
const preselectedItem = this.itemTargets.find(item => item.hasAttribute(this.selectionAttributeValue))
if (preselectedItem) {
this.#setCurrentFrom(preselectedItem)
}
}
// Stimulus won't let you handle keydown events with different handlers for the same (nested) stimulus controllers.
#relayNavigationToParentNavigableList(event) {
const parentController = this.#parentNavigableListController
if (parentController) {
parentController.element.focus({ preventScroll: !this.autoScrollValue })
parentController.navigate(event)
}
}
#selectPrevious() {
@@ -71,46 +180,26 @@ export default class extends Controller {
}
}
async #setCurrentFrom(element) {
const selectedItem = this.#visibleItems.find(item => item.contains(element))
const id = selectedItem?.getAttribute("id")
if (selectedItem) {
this.#clearSelection()
selectedItem.setAttribute(this.selectionAttributeValue, "true")
this.currentItem = selectedItem
await nextFrame()
try {
this.currentItem.scrollIntoView({ block: "nearest", inline: "nearest" })
} catch (e) {}
if (this.focusOnSelectionValue) { this.currentItem.focus() }
if (this.hasInputTarget && id) {
this.inputTarget.setAttribute("aria-activedescendant", id)
}
}
}
#clearSelection() {
for (const item of this.itemTargets) {
item.removeAttribute(this.selectionAttributeValue)
}
}
#handleArrowKey(event, fn, preventDefault = true) {
#handleArrowKey(event, fn) {
if (event.shiftKey || event.metaKey || event.ctrlKey) { return }
fn.call()
if (preventDefault) { event.preventDefault() }
if (this.preventHandledKeysValue) {
event.preventDefault()
}
}
#clickCurrentItem(event) {
if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length) {
if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length && this.#isFocusContainedOnNavigableItem) {
const clickableElement = this.currentItem.querySelector("a,button") || this.currentItem
clickableElement.click()
event.preventDefault()
}
}
get #isFocusContainedOnNavigableItem() {
return !this.onlyActOnFocusedItemsValue || this.itemTargets.some(item => item === document.activeElement || item.contains(document.activeElement))
}
#toggleCurrentItem(event) {
if (this.actionableItemsValue && this.currentItem && this.#visibleItems.length) {
const toggleable = this.currentItem.querySelector("input[type=checkbox]")
@@ -126,20 +215,34 @@ export default class extends Controller {
}
}
get #visibleItems() {
return this.itemTargets.filter(item => {
return item.checkVisibility() && !item.hidden
})
}
#keyHandlers = {
ArrowDown(event) {
const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this)
this.#handleArrowKey(event, selectMethod)
if (this.supportsVerticalNavigationValue) {
const selectMethod = this.reverseNavigationValue ? this.#selectPrevious.bind(this) : this.#selectNext.bind(this)
this.#handleArrowKey(event, selectMethod)
}
},
ArrowUp(event) {
const selectMethod = this.reverseNavigationValue ? this.#selectNext.bind(this) : this.#selectPrevious.bind(this)
this.#handleArrowKey(event, selectMethod)
if (this.supportsVerticalNavigationValue) {
const selectMethod = this.reverseNavigationValue ? this.#selectNext.bind(this) : this.#selectPrevious.bind(this)
this.#handleArrowKey(event, selectMethod)
}
},
ArrowRight(event) {
this.#handleArrowKey(event, this.#selectNext.bind(this), false)
if (this.supportsHorizontalNavigationValue) {
this.#handleArrowKey(event, this.#selectNext.bind(this))
}
},
ArrowLeft(event) {
this.#handleArrowKey(event, this.#selectPrevious.bind(this), false)
if (this.supportsHorizontalNavigationValue) {
this.#handleArrowKey(event, this.#selectPrevious.bind(this))
}
},
Enter(event) {
if (event.shiftKey) {
+11 -12
View File
@@ -1,15 +1,18 @@
const EDGE_THRESHOLD = 16
export function orient(el, orient = true) {
const directions = [
["orient-left", spaceOnRight],
["orient-right", spaceOnLeft],
["orient-top", spaceOnBottom]
];
el.classList.remove("orient-left", "orient-right")
directions.forEach(([className, fn]) =>
el.classList[orient && fn(el) < EDGE_THRESHOLD ? "add" : "remove"](className)
);
if (!orient) return
const rightSpace = spaceOnRight(el)
const leftSpace = spaceOnLeft(el)
if (rightSpace < EDGE_THRESHOLD && rightSpace < leftSpace) {
el.classList.add("orient-left")
} else if (leftSpace < EDGE_THRESHOLD && leftSpace < rightSpace) {
el.classList.add("orient-right")
}
}
function spaceOnLeft(el) {
@@ -19,7 +22,3 @@ function spaceOnLeft(el) {
function spaceOnRight(el) {
return window.innerWidth - el.getBoundingClientRect().right
}
function spaceOnBottom(el) {
return window.innerHeight - el.getBoundingClientRect().bottom
}
-12
View File
@@ -1,12 +0,0 @@
export function limitHeightToViewport(el, sizing = true, margin = 64) {
if (!el) return
if (sizing) {
const rect = el.getBoundingClientRect()
const top = Math.max(rect.top, margin)
const max = Math.max(0, window.innerHeight - margin - top)
el.style.maxHeight = `${max}px`
} else {
el.style.maxHeight = ""
}
}
+1 -1
View File
@@ -11,6 +11,6 @@ class Notification::BundleMailer < ApplicationMailer
mail \
to: bundle.user.identity.email_address,
subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: Latest Activity"
subject: "Fizzy#{ " (#{ Current.account.name })" if @user.identity.accounts.many? }: New notifications"
end
end
+4
View File
@@ -31,4 +31,8 @@ class Account < ApplicationRecord
def account
self
end
def system_user
users.where(role: :system).first!
end
end
+1 -1
View File
@@ -49,7 +49,7 @@ module Board::Accessible
end
def watchers
users.without(User.system).where(accesses: { involvement: :watching })
users.active.where(accesses: { involvement: :watching })
end
private
+1 -1
View File
@@ -26,7 +26,7 @@ module Card::Entropic
class_methods do
def auto_postpone_all_due
due_to_be_postponed.find_each do |card|
card.auto_postpone(user: User.system)
card.auto_postpone(user: card.account.system_user)
end
end
end
@@ -8,7 +8,7 @@ class Card::Eventable::SystemCommenter
def comment
return unless comment_body.present?
card.comments.create! creator: User.system, body: comment_body, created_at: event.created_at
card.comments.create! creator: card.account.system_user, body: comment_body, created_at: event.created_at
end
private
@@ -25,7 +25,7 @@ class Card::Eventable::SystemCommenter
when "card_postponed"
"#{event.creator.name} <strong>moved</strong> this to “Not Now”"
when "card_auto_postponed"
"<strong>Closed</strong> as “Not Now” due to inactivity"
"<strong>Moved</strong> to “Not Now” due to inactivity"
when "card_title_changed"
"#{event.creator.name} <strong>changed the title</strong> from “#{event.particulars.dig('particulars', 'old_title')}” to “#{event.particulars.dig('particulars', 'new_title')}”."
when "card_board_changed"
+1 -1
View File
@@ -64,7 +64,7 @@ class Event::Description
when "card_postponed"
%(#{creator} moved #{card_title} to "Not Now")
when "card_auto_postponed"
%(#{card_title} was closed as "Not Now" due to inactivity)
%(#{card_title} moved to "Not Now" due to inactivity)
when "card_resumed"
"#{creator} resumed #{card_title}"
when "card_title_changed"
-4
View File
@@ -18,10 +18,6 @@ class Identity < ApplicationRecord
end
end
def staff?
email_address.ends_with?("@37signals.com") || email_address.ends_with?("@basecamp.com")
end
private
def deactivate_users
users.find_each(&:deactivate)
+1 -1
View File
@@ -12,7 +12,7 @@ class Notification < ApplicationRecord
after_create_commit :broadcast_unread
after_destroy_commit :broadcast_read
after_create_commit :bundle
after_create :bundle
scope :preloaded, -> { preload(:creator, :account, source: [ :board, :creator ]) }
+6 -6
View File
@@ -15,7 +15,7 @@ class Notification::Bundle < ApplicationRecord
)
end
before_create :set_default_window
before_validation :set_default_window, if: :new_record?
validate :validate_no_overlapping
@@ -57,12 +57,12 @@ class Notification::Bundle < ApplicationRecord
deliver_later
end
private
def set_default_window
self.starts_at ||= Time.current
self.ends_at ||= self.starts_at + user.settings.bundle_aggregation_period
end
def set_default_window
self.starts_at ||= Time.current
self.ends_at ||= self.starts_at + user.settings.bundle_aggregation_period
end
private
def window
starts_at..ends_at
end
+3 -3
View File
@@ -3,7 +3,9 @@ class User < ApplicationRecord
Mentionable, Named, Notifiable, Role, Searcher, Watcher
include Timelined # Depends on Accessor
has_one_attached :avatar
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_fill: [ 256, 256 ]
end
belongs_to :account
belongs_to :identity, optional: true
@@ -17,8 +19,6 @@ class User < ApplicationRecord
scope :with_avatars, -> { preload(:account, :avatar_attachment) }
delegate :staff?, to: :identity, allow_nil: true
def deactivate
transaction do
accesses.destroy_all
+2 -1
View File
@@ -38,7 +38,7 @@ class User::DayTimeline
end
def closed_column
@closed_column ||= build_column("Closed", 3, events.where(action: "card_closed"))
@closed_column ||= build_column("Done", 3, events.where(action: "card_closed"))
end
def cache_key
@@ -55,6 +55,7 @@ class User::DayTimeline
card_collection_changed
card_board_changed
card_postponed
card_auto_postponed
card_triaged
card_sent_back_to_triage
comment_created
+8 -1
View File
@@ -16,13 +16,20 @@ module User::Notifiable
private
def find_or_create_bundle_for(notification)
find_bundle_for(notification) || create_bundle_for(notification)
find_bundle_for(notification) || expand_pending_bundle_for(notification) || create_bundle_for(notification)
end
def find_bundle_for(notification)
notification_bundles.pending.containing(notification).last
end
def expand_pending_bundle_for(notification)
pending = notification_bundles.pending.last
if pending.present? && notification.created_at < pending.starts_at
pending.update!(starts_at: notification.created_at) # expand the window to include this notification
end
end
def create_bundle_for(notification)
notification_bundles.create!(starts_at: notification.created_at)
end
-6
View File
@@ -8,12 +8,6 @@ module User::Role
scope :active, -> { where(active: true, role: %i[ admin member ]) }
end
class_methods do
def system
find_or_create_by!(role: :system) { it.name = "System" }
end
end
def can_change?(other)
admin? || other == self
end
+1 -1
View File
@@ -1,7 +1,7 @@
<div class="settings__panel settings__panel--entropy panel shadow center">
<header>
<h2 class="divider txt-large">Auto close</h2>
<p class="margin-none-block-start">Fizzy doesnt let stale cards stick around forever. Cards automatically close as “Not Now” without activity for specific period of time. <em>This is the default, global setting — you can override it on each board.</em></p>
<p class="margin-none-block-start">Fizzy doesnt let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for specific period of time. <em>This is the default, global setting — you can override it on each board.</em></p>
</header>
<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %>
+1 -1
View File
@@ -1,7 +1,7 @@
<%= turbo_frame_tag @board, :entropy do %>
<div class="margin-block-end">
<h2 class="divider txt-large">Auto close</h2>
<p class="margin-none-block-start">Fizzy doesnt let stale cards stick around forever. Cards automatically close as “Not now” if no one updates, comments, or moves a card for…</p>
<p class="margin-none-block-start">Fizzy doesnt let stale cards stick around forever. Cards automatically move to “Not now” if no one updates, comments, or moves a card for…</p>
<%= render "entropy/auto_close", model: board, url: board_entropy_path(board), disabled: !Current.user.can_administer_board?(@board) %>
</div>
<% end %>
+12 -16
View File
@@ -1,16 +1,12 @@
<section id="closed-cards" class="cards cards--on-deck is-collapsed" style="--card-color: var(--color-card-complete);"
data-drag-and-drop-target="container"
data-drag-and-strum-target="container"
data-collapsible-columns-target="column"
data-column-name="Done"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle"
data-drag-and-drop-url="<%= columns_card_drops_closure_path("__id__") %>"
>
<div class="cards__transition-container">
<header class="cards__header">
<%= render "boards/show/expander", title: "Done", count: board.cards.closed.count, column_id: "closed-cards" %>
<%= render "boards/show/menu/maximize", column_path: board_columns_closed_path(board) %>
</header>
<%= column_frame_tag :closed_column, src: board_columns_closed_path(board) %>
</div>
</section>
<%= column_tag id: "closed-cards", name: "Done", drop_url: columns_card_drops_closure_path("__id__"), class: "cards--closed", style: "--card-color: var(--color-card-complete);",
data: {
drag_and_strum_target: "container",
collapsible_columns_target: "column",
action: "focus->collapsible-columns#focusOnColumn"
} do %>
<header class="cards__header">
<%= render "boards/show/expander", title: "Done", count: board.cards.closed.count, column_id: "closed-cards" %>
<%= render "boards/show/menu/maximize", column_path: board_columns_closed_path(board) %>
</header>
<%= column_frame_tag :closed_column, src: board_columns_closed_path(board) %>
<% end %>
+17 -22
View File
@@ -1,23 +1,18 @@
<section id="<%= dom_id(column) %>"
class="cards cards--doing is-collapsed" style="--card-color: <%= column.color %>;"
data-drag-and-drop-target="container"
data-drag-and-strum-target="container"
data-collapsible-columns-target="column"
data-controller="clicker"
data-column-name="<%= column.name %>"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle turbo:before-stream-render@document->collapsible-columns#restoreState"
data-drag-and-drop-url="<%= columns_card_drops_column_path("__id__", column_id: column.id) %>"
>
<div class="cards__transition-container">
<header class="cards__header">
<%= render "boards/show/menu/column", column: column %>
<%= render "boards/show/expander", title: column.name, count: column.cards.active.count, column_id: dom_id(column) %>
<%= column_tag id: dom_id(column), name: column.name, drop_url: columns_card_drops_column_path("__id__", column_id: column.id), class: "cards--doing", style: "--card-color: #{column.color};",
data: {
drag_and_strum_target: "container",
collapsible_columns_target: "column",
controller: "clicker",
action: "turbo:before-stream-render@document->collapsible-columns#restoreState focus->collapsible-columns#focusOnColumn"
} do %>
<header class="cards__header">
<%= render "boards/show/menu/column", column: column %>
<%= render "boards/show/expander", title: column.name, count: column.cards.active.count, column_id: dom_id(column) %>
<%= link_to board_column_path(column.board, column), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= icon_tag "grid", class: "translucent" %>
<span class="for-screen-reader">Maximize column</span>
<% end %>
</header>
<%= column_frame_tag dom_id(column, :cards), src: board_column_path(column.board, column) %>
</div>
</section>
<%= link_to board_column_path(column.board, column), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= icon_tag "grid", class: "translucent" %>
<span class="for-screen-reader">Maximize column</span>
<% end %>
</header>
<%= column_frame_tag dom_id(column, :cards), src: board_column_path(column.board, column) %>
<% end %>
+9 -2
View File
@@ -1,17 +1,24 @@
<%= tag.div class: "card-columns hide-scrollbar", data: {
controller: "collapsible-columns drag-and-drop drag-and-strum",
controller: "collapsible-columns drag-and-drop drag-and-strum navigable-list",
drag_and_drop_dragged_item_class: "drag-and-drop__dragged-item",
drag_and_drop_hover_container_class: "drag-and-drop__hover-container",
collapsible_columns_board_value: board.id,
collapsible_columns_collapsed_class: "is-collapsed",
collapsible_columns_no_transitions_class: "no-transitions",
collapsible_columns_title_not_visible_class: "is-off-screen",
navigable_list_supports_vertical_navigation_value: false,
navigable_list_has_nested_navigation_value: true,
navigable_list_prevent_handled_keys_value: true,
navigable_list_auto_select_value: false,
navigable_list_auto_scroll_value: false,
action: "
keydown->navigable-list#navigate
dragstart->drag-and-drop#dragStart
dragover->drag-and-drop#dragOver
dragenter->drag-and-strum#dragEnter
drop->drag-and-drop#drop
dragend->drag-and-drop#dragEnd" } do %>
dragend->drag-and-drop#dragEnd
click@document->navigable-list#deselectWhenClickingOutside" } do %>
<div class="card-columns__left">
<%= render "boards/show/not_now", board: board %>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
<button class="cards__expander btn btn--plain" data-collapsible-columns-target="button" data-action="click->collapsible-columns#toggle"
<button class="cards__expander btn btn--plain" data-collapsible-columns-target="button" data-action="click->collapsible-columns#toggle click->navigable-list#selectCurrentOrReset"
style="--card-count: <%= [ count, 20 ].min %>" aria-controls="<%= column_id %>" aria-expanded="false">
<span class="cards__expander-count" data-drag-and-drop-counter="true"><%= count > 99 ? "99+" : count %></span>
<h2 class="cards__expander-title" data-collapsible-columns-target="title">
@@ -32,7 +32,7 @@
<% end %>
<% end %>
<%= link_to board_columns_closed_path(board), class: "cards cards--on-deck is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %>
<%= link_to board_columns_closed_path(board), class: "cards cards--closed is-collapsed", style: "--card-color: var(--color-card-complete);", data: { turbo_frame: "_top" } do %>
<div class="cards__expander btn btn--plain" style="--card-count: <%= board.cards.closed.count %>">
<span class="cards__expander-count"><%= board.cards.closed.count %></span>
<h2 class="cards__expander-title">
+12 -16
View File
@@ -1,16 +1,12 @@
<section id="not-now" class="cards cards--on-deck is-collapsed" style="--card-color: var(--color-card-complete);"
data-collapsible-columns-target="column"
data-drag-and-drop-target="container"
data-drag-and-strum-target="container"
data-column-name="Not Now"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle"
data-drag-and-drop-url="<%= columns_card_drops_not_now_path("__id__") %>"
>
<div class="cards__transition-container">
<header class="cards__header">
<%= render "boards/show/expander", title: "Not Now", count: board.cards.postponed.count, column_id: "not-now" %>
<%= render "boards/show/menu/maximize", column_path: board_columns_not_now_path(board) %>
</header>
<%= column_frame_tag :not_now_column, src: board_columns_not_now_path(board) %>
</div>
</section>
<%= column_tag id: "not-now", name: "Not Now", drop_url: columns_card_drops_not_now_path("__id__"), class: "cards--on-deck", style: "--card-color: var(--color-card-complete);",
data: {
collapsible_columns_target: "column",
drag_and_strum_target: "container",
action: "focus->collapsible-columns#focusOnColumn"
} do %>
<header class="cards__header">
<%= render "boards/show/expander", title: "Not Now", count: board.cards.postponed.count, column_id: "not-now" %>
<%= render "boards/show/menu/maximize", column_path: board_columns_not_now_path(board) %>
</header>
<%= column_frame_tag :not_now_column, src: board_columns_not_now_path(board) %>
<% end %>
+16 -22
View File
@@ -1,23 +1,17 @@
<section id="the-stream" class="cards cards--considering"
data-drag-and-drop-target="container"
data-column-name="Maybe?"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle"
data-drag-and-drop-url="<%= columns_card_drops_stream_path("__id__") %>">
<div class="cards__transition-container">
<header class="cards__header">
<div class="cards__expander">
<h2 class="cards__expander-title" data-collapsible-columns-target="title">Maybe?</h2>
</div>
<%= link_to board_columns_stream_path(board), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= icon_tag "grid", class: "translucent" %>
<span class="for-screen-reader">Expand column</span>
<% end %>
</header>
<%= render "columns/show/add_card_button", board: board %>
<% if page.used? %>
<%= with_automatic_pagination "the-stream", @page do %>
<%= render "boards/columns/list", cards: page.records, draggable: true %>
<% end %>
<%= column_tag id: "the-stream", name: "Maybe?", drop_url: columns_card_drops_stream_path("__id__"), collapsed: false, selected: "true", class: "cards--considering" do %>
<header class="cards__header">
<div class="cards__expander">
<h2 class="cards__expander-title" data-collapsible-columns-target="title">Maybe?</h2>
</div>
<%= link_to board_columns_stream_path(board), class: "cards__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= icon_tag "grid", class: "translucent" %>
<span class="for-screen-reader">Expand column</span>
<% end %>
</div>
</section>
</header>
<%= render "columns/show/add_card_button", board: board %>
<% if page.used? %>
<%= with_automatic_pagination "the-stream", @page do %>
<%= render "boards/columns/list", cards: page.records, draggable: true %>
<% end %>
<% end %>
<% end %>
+1 -1
View File
@@ -14,7 +14,7 @@
<div class="card__body justify-space-between">
<div class="card__content">
<%= render "cards/container/title", card: card %>
<%= render "cards/container/content", card: card %>
<%= render "cards/display/perma/steps", card: card %>
</div>
@@ -0,0 +1,8 @@
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item" aria-checked="<%= card.assignees.include?(user) %>">
<%= button_to card_assignments_path(card, params: { assignee_id: user.id }), method: :post,
class: "popup__btn btn", form_class: "max-width flex-item-grow" do %>
<span class="overflow-ellipsis flex-item-grow"><%= local_assigns.fetch(:user_label, user.name) %></span>
<%= yield if block_given? %>
<%= icon_tag "check", size: 18, class: "checked flex-item-no-shrink flex-item-justify-end", style: "--icon-size: 1em" %>
<% end %>
</li>
+7 -13
View File
@@ -1,5 +1,5 @@
<%= turbo_frame_tag @card, :assignment do %>
<%= tag.div class: "max-width", data: {
<%= tag.div class: "max-width full-width", data: {
action: "turbo:before-cache@document->dialog#close dialog:show@document->navigable-list#reset keydown->navigable-list#navigate filter:changed->navigable-list#reset",
controller: "filter navigable-list",
dialog_target: "dialog",
@@ -11,21 +11,15 @@
<kbd class="txt-xx-small hide-on-touch">a</kbd>
</div>
<% if @users.many? %>
<%= text_field_tag :search, nil, placeholder: "Filter…", class: "input input--transparent txt-small margin-block-half", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
<% end %>
<%= text_field_tag :search, nil, placeholder: "Filter…", class: "input input--transparent txt-small margin-block-half", autofocus: true,
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
<ul class="popup__list" data-filter-target="list">
<% @users.each do |user| %>
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item" aria-checked="<%= @card.assignees.include?(user) %>">
<%= button_to card_assignments_path(@card, params: { assignee_id: user.id }), method: :post,
class: "popup__btn btn", form_class: "max-width flex-item-grow" do %>
<span class="overflow-ellipsis flex-item-grow"><%= user.name %></span>
<%= icon_tag "check", size: 18, class: "checked flex-item-no-shrink flex-item-justify-end", style: "--icon-size: 1em" %>
<% end %>
</li>
<%= render "user", card: @card, user: Current.user, user_label: "Me" do %>
<span class="visually-hidden"><%= Current.user.name %></span>
<% end %>
<%= render collection: @assigned_to, partial: "user", locals: { card: @card } %>
<%= render collection: @users, partial: "user", locals: { card: @card } %>
</ul>
<% end %>
<% end %>
+1 -1
View File
@@ -8,7 +8,7 @@
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
data: { controller: "form local-save",
local_save_key_value: "comment-#{card.id}",
action: "turbo:submit-end->local-save#submit turbo:submit-end->form#blur keydown.ctrl+enter->form#debouncedSubmit:prevent keydown.meta+enter->form#debouncedSubmit:prevent keydown.esc->form#cancel:stop" } do |form| %>
action: "turbo:submit-end->local-save#submit turbo:submit-end->form#blurActiveInput keydown.ctrl+enter->form#debouncedSubmit:prevent keydown.meta+enter->form#debouncedSubmit:prevent keydown.esc->form#cancel:stop" } do |form| %>
<%= form.rich_textarea :body, required: true, placeholder: new_comment_placeholder(card),
data: { local_save_target: "input", action: "lexxy:change->form#disableSubmitWhenInvalid lexxy:change->local-save#save turbo:morph-element->local-save#restoreContent" } do %>
<%= general_prompts(@card.board) %>
+2 -2
View File
@@ -2,11 +2,11 @@
<strong class="txt-uppercase">Subscribers</strong>
<p class="margin-none-block-start margin-block-end-half">
<%= pluralize(card.watchers.without(User.system).count, "person") %> will be notified when someone comments on this.
<%= pluralize(card.watchers.active.count, "person") %> will be notified when someone comments on this.
</p>
<div class="flex align-center flex-wrap gap-half max-width txt-normal">
<% card.watchers.without(User.system).alphabetically.each do |watcher| %>
<% card.watchers.active.alphabetically.each do |watcher| %>
<%= avatar_tag watcher %>
<% end %>
</div>
+2 -7
View File
@@ -7,17 +7,12 @@
<% end %>
</div>
<% else %>
<%= button_to card_closure_path(card), class: "btn borderless",
data: { controller: "hotkey", form_target: "submit", action: "keydown.d@document->hotkey#click" },
form: { data: { controller: "form" } } do %>
<span class="overflow-ellipsis">Mark as Done</span>
<kbd class="txt-x-small hide-on-touch">d</kbd>
<% end %>
<%= render "cards/container/closure_buttons", card: card %>
<div id="<%= dom_id(card, :closure_notice) %>">
<% if card.entropic? && card.open? && !card.postponed? %>
<div class="card-perma__closure-message">
Closes as “Not Now” <%= local_datetime_tag(card.entropy.auto_clean_at, style: :indays) -%> if theres no activity.
Moves to “Not Now” <%= local_datetime_tag(card.entropy.auto_clean_at, style: :indays) -%> if theres no activity.
</div>
<% end %>
</div>
@@ -0,0 +1,15 @@
<div class="flex gap-half">
<%= link_to edit_card_path(card), class: "btn btn--circle-mobile borderless", data: { controller: "hotkey", action: "keydown.e@document->hotkey#click", turbo_frame: dom_id(card, :edit) } do %>
<%= icon_tag "pencil", class: "icon--mobile-only" %>
<span>Edit</span>
<kbd class="txt-x-small hide-on-touch">e</kbd>
<% end %>
<%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless",
data: { controller: "hotkey", form_target: "submit", action: "keydown.d@document->hotkey#click" },
form: { data: { controller: "form" } } do %>
<%= icon_tag "check", class: "icon--mobile-only" %>
<span class="overflow-ellipsis">Mark as Done</span>
<kbd class="txt-x-small hide-on-touch">d</kbd>
<% end %>
</div>
@@ -1,20 +1,12 @@
<% if card.published? %>
<div data-turbo-permanent>
<%= turbo_frame_tag card, :edit do %>
<h1 class="card__title flex align-start gap-half">
<%= link_to card.title, edit_card_path(card), class: "card__title-link" %>
</h1>
<% unless card.description.blank? %>
<div class="card__description rich-text-content" data-controller="syntax-highlight retarget-links">
<%= card.description %>
</div>
<%# When canceling an edit (with the ESC key), restore the button area to show "Edit" instead of "Save changes". %>
<%= turbo_stream.update dom_id(card, :card_closure_toggle) do %>
<%= render "cards/container/closure_buttons", card: card %>
<% end %>
<%= link_to edit_card_path(card), class: "btn fit-content txt-small", data: { controller: "hotkey", action: "keydown.e@document->hotkey#click" } do %>
<span>Edit</span>
<kbd class="txt-x-small hide-on-touch">e</kbd>
<% end %>
<%= render "cards/container/content_display", card: card %>
<% end %>
</div>
<% else %>
@@ -23,7 +15,7 @@
<%= form.label :title, class: "flex flex-column align-center autoresize__wrapper", data: { autoresize_target: "wrapper", autoresize_clone_value: "" } do %>
<%= form.text_area :title, placeholder: "Name it…",
class: "card-field__title autoresize__textarea input input--textarea full-width borderless txt-align-start hide-focus-ring hide-scrollbar",
autofocus: card.title.blank?, rows: 1, dir: "auto",
autofocus: card.title.blank?, rows: 1, dir: "auto", maxlength: 255,
data: { autoresize_target: "textarea", action: "input->autoresize#resize auto-save#change blur->auto-save#submit keydown.enter->auto-save#submit:prevent" } %>
<% end %>
</h1>
@@ -0,0 +1,9 @@
<h1 class="card__title flex align-start gap-half">
<%= link_to card.title, edit_card_path(card), class: "card__title-link" %>
</h1>
<% unless card.description.blank? %>
<div class="card__description rich-text-content" data-controller="syntax-highlight retarget-links">
<%= card.description %>
</div>
<% end %>
@@ -0,0 +1,7 @@
<div class="flex gap-half">
<%= button_tag type: :submit, form: dom_id(card, :edit_form), class: "btn borderless",
data: { controller: "hotkey", action: "keydown.ctrl+enter@document->hotkey#click keydown.meta+enter@document->hotkey#click" } do %>
<span>Save changes</span>
<kbd class="txt-x-small hide-on-touch"><%= hotkey_label(["ctrl", "enter"]) %></kbd>
<% end %>
</div>
+5 -5
View File
@@ -1,7 +1,11 @@
<% draggable = local_assigns.fetch(:draggable, false) && card.published? %>
<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item" } do %>
<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item" }, tabindex: 0 do %>
<div class="flex flex-column flex-item-grow max-inline-size">
<%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %>
<span class="for-screen-reader"><%= card.title %></span>
<% end %>
<header class="card__header">
<%= render "cards/display/preview/board", card: card %>
<%= render "cards/display/preview/tags", card: card %>
@@ -31,10 +35,6 @@
<%= render "cards/display/common/background", card: card %>
</footer>
<%= link_to card_path(card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %>
<span class="for-screen-reader"><%= card.title %></span>
<% end %>
<% if card.entropic? %>
<%= render "cards/display/preview/bubble", card: card %>
<% end %>
@@ -3,8 +3,8 @@
<li id="<%= dom_id(card, :new_step) %>" class="step">
<input type="checkbox" class="step__checkbox" disabled>
<%= form_with model: [card, Step.new], url: card_steps_path(card), data: { controller: "form", action: "submit->form#preventEmptySubmit turbo:submit-end->form#reset" } do |form| %>
<%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input" }, aria: { label: "Add a step" } %>
<%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit turbo:submit-end->form#reset" } do |form| %>
<%= form.text_field :content, class: "input step__content hide-focus-ring", placeholder: "Add a step…", autocomplete: "off", data: { form_target: "input", "1p-ignore": "true" }, aria: { label: "Add a step" } %>
<% end %>
</li>
</ol>
+9 -6
View File
@@ -1,10 +1,17 @@
<%= turbo_frame_tag @card, :edit do %>
<%= form_with model: @card,
<%# When entering edit mode, this turbo-stream replaces the button area to show
"Save changes" instead of "Edit". Turbo processes this stream as part of the
frame response. %>
<%= turbo_stream.update dom_id(@card, :card_closure_toggle) do %>
<%= render "cards/container/save_button", card: @card %>
<% end %>
<%= form_with model: @card, id: dom_id(@card, :edit_form),
data: { controller: "autoresize form local-save", local_save_key_value: "card-#{@card.id}", action: "turbo:submit-end->local-save#submit" } do |form| %>
<h1 class="card__title flex align-start gap-half">
<%= form.label :title, class: "flex flex-column align-center autoresize__wrapper", data: { autoresize_target: "wrapper", autoresize_clone_value: "" } do %>
<%= form.text_area :title, class: "card-field__title autoresize__textarea input input--textarea full-width borderless txt-align-start hide-focus-ring hide-scrollbar",
required: true, autofocus: true, placeholder: "Name it…", rows: 1, dir: "auto",
required: true, autofocus: true, placeholder: "Name it…", rows: 1, dir: "auto", maxlength: 255,
data: { autoresize_target: "textarea", action: "input->autoresize#resize keydown.enter->form#submit:prevent keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel focus->form#select" } %>
<% end %>
</h1>
@@ -15,10 +22,6 @@
<%= general_prompts(@card.board) %>
<% end %>
<%= form.button type: :submit, class: "btn btn--reversed fit-content txt-small", data: { form_target: "submit" } do %>
<span>Save changes</span>
<kbd class="txt-x-small hide-on-touch"><%= hotkey_label([ "ctrl", "enter" ]) %></kbd>
<% end %>
<%= link_to "Close editor and discard changes", @card, data: { form_target: "cancel" }, hidden: true %>
<% end %>
<% end %>
+1 -1
View File
@@ -3,7 +3,7 @@
<%= form_with model: [@card, @step], class: "step", data: { controller: "form" } do |form| %>
<%= form.check_box :completed, { class: "step__checkbox", checked: @step.completed?, disabled: true } %>
<%= form.text_field :content, class: "input step__content step__content--edit hide-focus-ring", placeholder: "Name this step…", required: true, autofocus: true, autocomplete: "off",
data: { action: "keydown.esc->form#cancel focus->form#select" } %>
data: { action: "keydown.esc->form#cancel focus->form#select", "1p-ignore": "true" } %>
<%= form.button type: "submit", class: "btn btn--positive txt-xx-small" do %>
<%= icon_tag "check" %>
<span class="for-screen-reader">Save changes</span>
+8
View File
@@ -0,0 +1,8 @@
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item" aria-checked="<%= card.tagged_with?(tag) %>">
<%= button_to card_taggings_path(card, params: { tag_title: tag.title }), method: :post,
class: "popup__btn btn",
form_class: "max-width flex-item-grow" do %>
<span class="overflow-ellipsis flex-item-grow"><%= tag.hashtag %></span>
<%= icon_tag "check", size: 18, class: "checked flex-item-no-shrink flex-item-justify-end", style: "--icon-size: 1em" %>
<% end %>
</li>
+2 -10
View File
@@ -18,16 +18,8 @@
<% end %>
<ul class="popup__list" data-filter-target="list">
<% @tags.each do |tag| %>
<li class="popup__item" data-filter-target="item" data-navigable-list-target="item" aria-checked="<%= @card.tagged_with?(tag) %>">
<%= button_to card_taggings_path(@card, params: { tag_title: tag.title }), method: :post,
class: "popup__btn btn",
form_class: "max-width flex-item-grow" do %>
<span class="overflow-ellipsis flex-item-grow"><%= tag.hashtag %></span>
<%= icon_tag "check", size: 18, class: "checked flex-item-no-shrink flex-item-justify-end", style: "--icon-size: 1em" %>
<% end %>
</li>
<% end %>
<%= render collection: @tagged_with, partial: "tag", locals: { card: @card } %>
<%= render collection: @tags, partial: "tag", locals: { card: @card } %>
<li class="popup__item" data-navigable-list-target="item">
<%= button_tag "Create a new tag", type: "submit", form: dom_id(@card, :tags_form), class: "btn popup__btn", data: { form_target: "submit" } do %>
+9
View File
@@ -0,0 +1,9 @@
<%= turbo_stream.replace dom_id(@card, :card_container), partial: "cards/container", method: :morph, locals: { card: @card.reload } %>
<%= turbo_stream.update dom_id(@card, :edit) do %>
<%= render "cards/container/content_display", card: @card %>
<% end %>
<%= turbo_stream.update dom_id(@card, :card_closure_toggle) do %>
<%= render "cards/container/closure_buttons", card: @card %>
<% end %>
@@ -1,7 +1,7 @@
<div class="header__actions header__actions--end">
<div>
<%= link_to new_board_path, class: "btn btn--link btn--circle-mobile", data: { controller: "hotkey", action: "keydown.b@document->hotkey#click" } do %>
<%= icon_tag "board", class: "show-on-touch" %>
<%= icon_tag "board" %>
<span class="overflow-ellipsis">Add a board</span>
<kbd class="hide-on-touch">B</kbd>
<% end %>
@@ -1,7 +1,7 @@
<div class="header__actions header__actions--start">
<% if board = user_filtering.single_board_or_first %>
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile", data: { controller: "hotkey", action: "keydown.c@document->hotkey#click" } do %>
<%= icon_tag "add", class: "show-on-touch" %>
<%= icon_tag "add" %>
<span class="overflow-ellipsis">Add a card</span>
<kbd class="hide-on-touch">C</kbd>
<% end %>
+2 -1
View File
@@ -8,6 +8,7 @@
<% else %>
<button type="button" class="btn txt-x-small filter-toggle" data-action="toggle-class#toggle toggle-enable#toggle" data-controller="tooltip">
<%= icon_tag "filter" %>
<span class="for-screen-reader">Expand filter options</span>
<span class="filters__show-when-expanded for-screen-reader">Collapse filter options</span>
<span class="filters__show-when-collapsed for-screen-reader">Expand filter options</span>
</button>
<% end %>
+3 -3
View File
@@ -1,12 +1,12 @@
<% @page_title = "You can't join #{@join_code.account.name} right now." %>
<% @page_title = "That code is all used up" %>
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-none"><%= @page_title %></h1>
<p class="txt-medium">This join code has no invitations left on it.</p>
<p class="txt-medium margin-none">Ask someone from <%= @join_code.account.name %> to send you a new link or increase the limit.</p>
<p class="txt-medium">
<%= link_to "Check out Fizzy", "https://www.fizzy.do" %>.
<%= link_to "OK", "https://www.fizzy.do", class: "btn btn--link" %>
</p>
</div>
+3 -1
View File
@@ -49,13 +49,15 @@
}
.avatar {
border-radius: 50%;
aspect-ratio: 1;
border-radius: 2.5em;
color: white;
display: block;
font-weight: 600;
height: 2.5em;
line-height: 2.5em;
mso-line-height-rule: exactly;
object-fit: cover;
overflow: hidden;
text-align: center;
width: 2.5em;
+1 -1
View File
@@ -7,7 +7,7 @@
<% end %>
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#0d181d" media="(prefers-color-scheme: dark)">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= tag.meta name: "current-user-id", content: Current.user.id if Current.user %>
@@ -1,6 +1,6 @@
<tr>
<td>
<h1 class="title">Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %></h1>
<h1 class="title">Notifications since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %></h1>
<p class="subtitle margin-block-end-double">
You have <%= link_to pluralize(@notifications.count, "new notification"), notifications_url %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.
</p>
@@ -1,4 +1,4 @@
Everything since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>
Notifications since <%= @bundle.starts_at.strftime("%-l%P on %A, %B %-d") %>
You have <%= pluralize @notifications.count, "new notification" %><%= " in #{ Current.account.name }" if @user.identity.accounts.many? %>.
<% @notifications.group_by(&:card).each do |card, notifications| %>
+1 -1
View File
@@ -2,7 +2,7 @@
data-controller="dialog"
data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames">
<%= tag.button class:"nav__trigger input input--select center flex-inline align-center txt-normal", data: {
action: "click->dialog#open:stop keydown.j@document->hotkey#click keydown.meta+j@document->hotkey#click keydown.ctrl+j@document->hotkey#click",
action: "click->dialog#open keydown.j@document->hotkey#click keydown.meta+j@document->hotkey#click keydown.ctrl+j@document->hotkey#click",
controller: "hotkey" } do %>
<span><%= image_tag "logo.png" %></span>
<svg height="30" viewBox="0 0 96 30" width="96" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m93.5609 8.52856c.9033.04314 1.3769.47352 1.4199 1.33398.1291 2.40966.0859 5.24976.1289 7.48726l.1289 7.3575c0 15.4902-10.2406 15.8354-16.8242 14.7168-.8606-.1722-1.2482-.7322-1.1621-1.5928l.3867-3.7002c.086-1.0327.732-1.2905 1.6787-.9463 4.3889 1.5919 9.122-.4737 9.0791-4.7334 0-1.0757-.6026-1.2052-1.2481-.3877-.9036 1.0757-2.2802 2.1943-4.3886 2.1944-3.0552 0-8.2188-1.2046-8.2188-11.962 0-2.6677-.086-5.5076-.0429-8.47652 0-.90362.5162-1.37702 1.4199-1.33399 1.2048.04302 2.3665.00006 3.5283-.04297.9036 0 1.4199.47328 1.4199 1.41992 0 3.22686-.0859 5.63626-.0859 6.45406 0 7.7023 1.7647 8.2187 3.7871 8.2618 2.1512.0427 3.5709-1.5491 3.8291-5.2061v-.9902c0-4.604.0861-6.2821-.043-8.47659-.086-.9036.3444-1.41986 1.2481-1.46289zm-43.4629-.21484c1.1186-.04303 1.5064.68823.9472 1.63476-1.8502 3.05502-5.3796 8.73462-8.3486 13.42482-.4733.7745-.1713 1.334.7754 1.334 2.1511.043 2.9261.0431 5.5937-.086.9464-.0429 1.4199.4306 1.42 1.377-.0431.8605-.0001 1.8504 0 2.7109 0 .9036-.4305 1.3769-1.334 1.377-3.9588.043-9.4238-.1721-15.6201-.043-1.1618 0-1.5487-.6881-.9463-1.6348l8.3906-13.2099c.4733-.7315.1721-1.3769-.7315-1.377-2.4096-.043-4.9915.0429-6.8418.1289-.9036.0431-1.4199-.3874-1.4199-1.291-.043-.9035 0-1.9792 0-2.92576 0-.86059.7316-1.33399 2.0225-1.33399 6.1531.04303 12.1341-.04291 16.0928-.08593zm21.1367 0c1.1186-.04294 1.5056.68817.9463 1.63476-1.8503 3.05502-5.3787 8.73462-8.3477 13.42482-.4733.7745-.1721 1.3339.7744 1.334 2.1514.043 2.9261.0431 5.5938-.086.9465-.043 1.4198.4305 1.4199 1.377-.043.8605 0 1.8504 0 2.7109 0 .9036-.4304 1.377-1.334 1.377-3.9586.043-9.4232-.1721-15.6191-.043-1.1617 0-1.5494-.6883-.9473-1.6348l8.3916-13.2099c.4733-.7315.1712-1.377-.7324-1.377-2.4096-.043-4.9916.0429-6.8418.1289-.9033.0429-1.4199-.3875-1.4199-1.291-.0431-.9035 0-1.9792 0-2.92576 0-.86051.7318-1.3339 2.0224-1.33399 6.1533.04303 12.135-.04291 16.0938-.08593zm-43.3252.25781c.9035-.04295 1.4199.38753 1.4199 1.24805.043 1.50602 0 3.65782 0 12.39262 0 5.6796-.086 5.4215 0 6.4111.1291.9036-.3444 1.4198-1.248 1.4629l-3.9161-.086c-.8604-.0431-1.3769-.4735-1.4199-1.3339-.1291-2.4097-.0859-5.2499-.1289-7.4874-.1291-5.5505-.0429-7.8742-.0859-11.23042-.043-.90357.4733-1.37695 1.4199-1.37695 1.7212.08606 2.8832.08606 3.959 0zm-21.88185-8.56250162c2.36657.04302752 4.77645.00000269 8.30465 0 1.2909 0 3.0554.04302142 4.6905 0 .7314 0 1.119.38683562 1.1191 1.16113162-.043 1.46292-.086 3.3134-.043 4.77637 0 .77451-.4305 1.11924-1.205 1.0332-1.2909-.12909-3.3564-.25866-5.5079-.34473-2.0653-.12908-2.1945.04296-4.25972.04297-.8606 0-1.42065.43055-1.59277 1.20508v.04297c-.12909.64544-.12891 1.03327-.12891 1.67871v.51567c.04303.9035.51653 1.3338 1.37696 1.3769 2.40954.043 5.46464.0002 7.70214-.1289.7315-.043 1.1621.3016 1.1621 1.0762l-.0859 4.3886c0 .7315-.4306 1.1192-1.1621 1.0762-2.2374-.043-4.7329-.0439-7.35746-.0869-.9035-.043-1.37677.4736-1.41992 1.334l-.17285 4.3467c0 2.6244.04379 4.0872.17285 5.292.12909.7315-.25872 1.1621-.99023 1.1621l-5.29297.0429c-.731221-.0001-1.075196-.3877-1.075196-1.1191-.000045-1.2478-.2149575-2.8399-.128907-5.7656.129086-5.7659.085256-13.7261-.12988242-21.81546-.04302878-.774508.34469842-1.162087 1.07617542-1.162105 1.592-.0430291 3.70034-.1719109 4.94824-.12890662zm19.90235.34374962c2.6247.00004 3.5283 1.377542 3.5283 3.055662-.0001 1.72099-.9038 3.09762-3.5283 3.09766-2.6677 0-3.5712-1.37665-3.5713-3.09766 0-1.67815.9034-3.055662 3.5713-3.055662z" fill="currentColor" fill-rule="evenodd"/></svg>
+2 -24
View File
@@ -13,30 +13,8 @@
<div data-controller="badge navigable-list" data-badge-unread-class="unread" data-action="keydown@document->navigable-list#navigate" data-navigable-list-actionable-items-value="true"
data-navigable-list-focus-on-selection-value="false">
<section class="notifications-list panel panel--wide center borderless unpad flex flex-column gap-half">
<% if @unread.any? %>
<div class="flex align-center justify-space-between margin-block-start margin-block-end-half">
<h2 class="txt-medium txt-uppercase txt-alert">New for you</h2>
<%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %>
</div>
<% else %>
<div class="notifications-list__empty-message pad border-radius border translucent" style="--border-style: dashed; --border-color: var(--color-ink); --border-radius: 0.2em;">
<strong>Nothing new for you.</strong>
</div>
<% end %>
<div id="notifications_list" contents>
<%= render partial: "notifications/index/notification", collection: @unread, cached: true %>
</div>
</section>
<section class="notifications-list notifications-list--read panel panel--wide center borderless unpad flex flex-column gap-half margin-block-start">
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
<div id="notifications_list_read" contents>
<%= render partial: "notifications/index/notification", collection: @page.records, cached: true %>
</div>
</section>
<%= render "notifications/index/unread_notifications", unread: @unread if @unread %>
<%= render "notifications/index/read_notifications", page: @page %>
</div>
<%= notifications_next_page_link(@page) if @page.records.any? %>
@@ -0,0 +1,7 @@
<section class="notifications-list notifications-list--read panel panel--wide center borderless unpad flex flex-column gap-half margin-block-start">
<h2 class="txt-medium margin-block-start-double margin-block-end-half txt-uppercase translucent">Previously seen</h2>
<div id="notifications_list_read" contents>
<%= render partial: "notifications/index/notification", collection: page.records, cached: true %>
</div>
</section>
@@ -0,0 +1,25 @@
<section class="notifications-list panel panel--wide center borderless unpad flex flex-column gap-half">
<% if unread.any? %>
<div class="flex align-center justify-space-between margin-block-start margin-block-end-half">
<h2 class="txt-medium txt-uppercase txt-alert">New for you</h2>
<%= button_to "Mark all as read", bulk_reading_path, class: "btn txt-small", form: { data: { turbo: false } }, data: { action: "badge#clear" } %>
</div>
<% else %>
<div class="notifications-list__empty-message pad border-radius border translucent" style="--border-style: dashed; --border-color: var(--color-ink); --border-radius: 0.2em;">
<strong>Nothing new for you.</strong>
</div>
<% end %>
<div id="notifications_list" contents>
<%= render partial: "notifications/index/notification", collection: unread, cached: true %>
</div>
<% if unread.any? %>
<% total_unread_count = Current.user.notifications.unread.count %>
<% if Current.user.notifications.unread.count > NotificationsController::MAX_UNREAD_NOTIFICATIONS %>
<div class="fill-highlight txt-x-small border-radius pad-block-half pad-inline">
Showing the <%= NotificationsController::MAX_UNREAD_NOTIFICATIONS %> most recent (<%= total_unread_count - NotificationsController::MAX_UNREAD_NOTIFICATIONS %> are hidden)
</div>
<% end %>
<% end %>
</section>
@@ -1,4 +1,4 @@
<section id="closed-cards" class="cards cards--on-deck is-collapsed" style="--card-color: var(--color-card-complete);"
<section id="closed-cards" class="cards cards--closed is-collapsed" style="--card-color: var(--color-card-complete);"
data-collapsible-columns-target="column"
data-action="turbo:before-morph-attribute->collapsible-columns#preventToggle"
>
+1 -1
View File
@@ -28,7 +28,7 @@
</header>
<div class="card__body justify-space-between">
<%= render "public/cards/show/title", card: @card %>
<%= render "public/cards/show/content", card: @card %>
<%= render "cards/display/public_preview/columns", card: @card if @card.open? %>
</div>
+1 -3
View File
@@ -36,7 +36,5 @@
"url": "<%= root_path %>",
"icons": [{ "src": "<%= image_url("activity.svg") %>", "sizes": "any" }]
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff"
]
}
+1 -1
View File
@@ -9,7 +9,7 @@
data: {
bar_target: "searchInput",
action: "keydown.enter->bar#showModalAndSubmit:prevent keydown.esc->bar#reset" } %>
<button class="search__reset btn btn--circle borderless" data-action="bar#reset">
<button class="search__reset btn btn--circle borderless" data-action="bar#clearInput">
<%= icon_tag "close" %>
<span class="for-screen-reader">Close search</span>
</button>
-7
View File
@@ -1,10 +1,3 @@
<div class="search__header">
<button class="btn txt-x-small" data-action="bar#reset">
<%= icon_tag "remove" %>
<span class="for-screen-reader">Close</span>
</button>
</div>
<section class="search">
<div class="search__results">
<% unless page.used? %>
+1 -1
View File
@@ -1 +1 @@
SlqumuYcQYbvuwMZNdQf54cXZ4zBavRLDyl6eiWzEYe/nGlGOlzAYpsPs3ZUA4bJC9tVm8c29E0FzmmG5X6b18ShCB8y6qMiSMEU8CeyMCIlvi0+CeWQPMbujA8ro5P6eE8deZ4+nN1pEqCpK8XCx+HFypx5sLpZ9G2uAPOBNPtOqX9KGXvWfe6RyHvR6+mk+a0jpPs50y2sQvA+/l77h6VnT/uoaa7P6q0YkZWmCWwsPT5ikgP5w+Ur+yn/VfWWS70CIqVSZhZJrzq96OV6GxKLLFdE/2im83Gg2vKODbu10FiQAdjwyubietv4PHLagtXux/mGRtBAzvwt2mKJpOxZFbUYr3u4luplTAxwMMvGMoEvxXs8ujTHjcWL8BCd3TjjOvuB82+/hYI/ds28GwMxDmurKlP0TfQXEUmrx27+mF5Bi/BNyaGwD3EMpP5f71HWxTgdTI2SVbM1pVHS3wnnnhvI4r9TcApt4oasZ2HcUJ1tqYvxDGShFnlsjB0XfJYQg5IM7/6PBr2FMi+7czz7gSaCiUJ/ew5HuTzXzS1NdNKaP407SIMjB8yKGmOTPlIf2Pe/i5kj6CVVV/ALPantfTddqpEXDjfSCYv+UwY5dKGKZXFzcbtQa9/LB+q1tPTi8Xo94CnxrSeJIzUJ6P3VXM9ihQn72GWrMz5Mt58VTf6yQve3gTq0gOIpLpMhOwwsWJ0tvOu5/4ma95qG4WTb7XKJIwmT6p0T7dBm8CJgigZckkme+Qob5nxUzg9pc1YQ9t5a3r0E1wCEdEeqXzBHgLfjjrF2ohYJwGWyw0zUqRxVYgrThpsIA9x4DqzFOAMRP76IV7WTCNTCoGslteV0hSFctBp1dAPUnN9bmvoWs4VS6n/tOxruYwEKLKGXzfo897LSMK6lEa1YF5r7BJIwSC1LxQ/60zuW8JYFV/4gyR3e7J2u0apExCZyohCDHgA/5Ol2mANuXIScFo6ikBJBKDWEN1GBjEA6aw0G0cY34JXJ73oN3JH8PRDmDAmd5RA3S97lyadPZxmvssN4pvncA6wj6ow3u27+TS+yDVYhtiri5Ce661Ps5sU9ih1EvxDvaoPTQnsWsvZ4/A3ybO0aNGDwlDcziNWV7+DZ6dDO6MHC93M6REYtDIjvtfSmnGC+8GNwoaa5rDCtrtbs4dSCYe3CIn9oLS03GgoTFdTpPKPNucwjN3yDIWGQ/HNJt2eCZvdVuljDWOiHR+1/rZZR/JNVvfhQRJdr+jT0gom0b9o/K3xh6N7VCPZMxalQHgELCkNa/+9WZ28L+6gpi7lEKy8dXdIoNlNE5Mpre/PiOHJViQi1ZquIM6h8fT09hv5bnT3s6zumdL7piUljRr00buCqWGF6UYNf3/3e6WeYPCKgxcDfVvi49AL4zrPKeEeW2KUwIAC0Vz0gbdh1bAAmdtjuQd4bXMMohcend69KV1nLByuLksmnyfHMXNnYmj0YHQI=--d+g/vj0Xg+A57CDr--GutJH+N6jhC1+wTQ52DuCA==
6Ciuxv75imDwiIXYs8lVxsDClQq+76RRr9gvVSxbDNpQhhuETHtMZZPiK2yfHtnFwwm/CrkpWhIRy+ZUt+9bQjnz3J3dMwPmsHdmKTnxUeiKuj+7k5QPKDHc0G1kfpxRANsB10WEcjt5IeoNcNxTrXYbja3NX2OhHYOFWMW/jLpGIXRPnBw0+JgQElKQI42vm1APix/9DRwqrM7TmgjXjJw4q4C7WeSgc4Y7+sHF5T2wJrtkdmExgD7X+9BTI7FOHkpwmQIYEWe34P1/BSFgFcy6tgwI/5U74QUcsj/dEgtKADukLQK9eMPLbfbJS4X7WUD1bDrOnmix5WQy0kdERq4NGSzdvumOhOY4LKYqS3otnPuTIZObbduLxPY9mgd8VFXfev8EjCrrVcVLVNUvtqTz+ri4fYAaHGTRQXttV2oOKtAik8JAggSrY3W5zZGWPEknDD704jyzi4ZRhJ2goMf+vvNZeH6pcQeWlLrIBxrfghGDrhDiHYFqaub2+2qeHhhkenOGtsttHiG4lpfcgmEa2Sr6ADdcxcmOi9qMt17LFmvplw2jR13aIUfglw4K2mzaikCUkDbgKRPqjKpbwkKbdJOaGFx69Oa4ZtiXX+ecKSgGhxhyIOyg7c8NtGeUNAZbjVEzdG8jqj+WBPLzozvpE+3y/dHgQ8MXegf5fsoov/2cpO2HK9tAepgjPyfAU7Rl5nzLrbGPsvgZ1/VRWr/Li6baZBVKrdZnWrjqLLfgqT28deVIHfwrlspifKq2wtm0eIyNGDs3Sm9wIYc1H2QMBe0zVly9rpQBnD8f901CzFQ4PN9ZIn1ZxdoNsdPdc/26mNHwa1UvVyPxgfLMBhlofRRpLNXLh1HOEzBYxuoHtJ3f86G0UwcxkhG7dTxbm8ujQ8RmYfHfgOEBJET/P7rILflXEtRsKPfkNjZqHIt0AeEjpnA5ZmSYZrpsjAQ8FnblgCj32/QfKBsW7MIvJ18w2AOap+z7v9RyJTB9Pq93KdR232xXg7U9sX/qUMI3j6+Gx0URrTFlWCDXpq8QYK+BN/vqUfe39rSxKh/qy+JCbk1B/Ftd52Cn+gsG/fHnVaKyCK04UZSrYllqg82gMrl6A39v8PAiD/x1VFxiZzSm/oi8rBAVI9elOzxXzSr0lDUr+w0iXmiJUzSzLkgtd6nf74cjEmitZQqRRgo9/YANJQkh2HiABHuevHsBgcebw+FOM4VO9y4lljY/Ex98echppRjZs4DgJYK2/R4uQBOMBTlxuqdwuSGodYSlmI5kW43bZGTTA5KJa7w1DRmpVhQfoReWg5EDxifXh2sUkFqTMOejOauQm5fjN9RxnXA1c7b7mxpvBgkepUEI+o+AcbzNlAXG/orcm9zlt2sC8YoDR1T01blYEhoAM6IwqXCXt6ODxKEgMGQ/S3EhDWQQ989lR2gmoMjjbkwxU75pT/SDWJi/A2kFBC+e952Al+iybFxaUM9VpNfPrLB2iNIOO5McQJCH/EjD8+/ZfhhSin5fAl0GFFZyyk0Shd0+TWkuJk0mtAf3ev1sohzGFieCcB5Shj4oNcqWTvPisb4DCoO2AOnEo3adNPA3zCDRvSWv+rwsXEzqW2w2Wav4cAV8VqyKj/6NYIbvwbjwD3ojnpB41705+OekhFubgTLLCnk3+tbNgaLlBcnUawuHW4akZezmUCUoOYoLRzGEQwxCGMG6Hn9lNDw46Uyv7VsggpaNVORTeYGX1Om25QcDM+OBoYQynD24gg==--UIlvvCt/ukIZVxPZ--en6RIr54eZ9nuOvN+prX4w==
+3
View File
@@ -5,9 +5,11 @@
if ENV["MIGRATE"].present?
mysql_app_user_key = "MYSQL_ALTER_USER"
mysql_app_password_key = "MYSQL_ALTER_PASSWORD"
max_execution_time_ms = 0 # No limit
else
mysql_app_user_key = "MYSQL_APP_USER"
mysql_app_password_key = "MYSQL_APP_PASSWORD"
max_execution_time_ms = 5_000
end
mysql_app_user = ENV[mysql_app_user_key]
@@ -27,6 +29,7 @@ default: &default
timeout: 5000
variables:
transaction_isolation: READ-COMMITTED
max_execution_time: <%= max_execution_time_ms %>
<% end %>
development:

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