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:
Executable
+37
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 +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
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%);
|
||||
|
||||
@@ -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
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
|
||||
@supports (field-sizing: content) {
|
||||
field-sizing: content;
|
||||
max-inline-size: 100%;
|
||||
min-inline-size: 15ch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -31,4 +31,8 @@ class Account < ApplicationRecord
|
||||
def account
|
||||
self
|
||||
end
|
||||
|
||||
def system_user
|
||||
users.where(role: :system).first!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ]) }
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 doesn’t 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 doesn’t 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,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 doesn’t 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 doesn’t 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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 there’s no activity.
|
||||
Moves to “Not Now” <%= local_datetime_tag(card.entropy.auto_clean_at, style: :indays) -%> if there’s 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>
|
||||
+5
-13
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -36,7 +36,5 @@
|
||||
"url": "<%= root_path %>",
|
||||
"icons": [{ "src": "<%= image_url("activity.svg") %>", "sizes": "any" }]
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 @@
|
||||
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==
|
||||
@@ -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
Reference in New Issue
Block a user