diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 86c66c646..f4f755614 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,8 +20,14 @@ updates: dependency-type: "development" schedule: interval: weekly + cooldown: + default-days: 7 + semver-major-days: 14 - package-ecosystem: github-actions directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 + cooldown: + default-days: 7 + semver-major-days: 14 diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 4fdc1bcba..f3b992387 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -3,6 +3,9 @@ name: Checks on: pull_request: +permissions: + contents: read + jobs: security: name: Security diff --git a/.github/workflows/ci-oss.yml b/.github/workflows/ci-oss.yml index f6f490f34..ea007b567 100644 --- a/.github/workflows/ci-oss.yml +++ b/.github/workflows/ci-oss.yml @@ -4,6 +4,9 @@ on: pull_request: types: [opened, synchronize] +permissions: + contents: read + jobs: test: if: github.event.pull_request.head.repo.full_name != github.repository diff --git a/.github/workflows/ci-saas.yml b/.github/workflows/ci-saas.yml index f1d8875b4..aa98a8e93 100644 --- a/.github/workflows/ci-saas.yml +++ b/.github/workflows/ci-saas.yml @@ -3,6 +3,9 @@ name: CI (SaaS) on: push: +permissions: + contents: read + jobs: test_oss: name: Test (OSS) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b5925a13..e62118357 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,9 @@ on: GH_TOKEN: required: false +permissions: + contents: read + jobs: test: name: Tests (${{ matrix.mode }}) diff --git a/AGENTS.md b/AGENTS.md index 428c802a8..80bb887a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,4 +148,4 @@ Use Chrome MCP tools to interact with the running dev app for UI testing and deb ## Coding style -Please read the separate file `STYLE.md` for some guidance on coding style. +@STYLE.md diff --git a/Dockerfile b/Dockerfile index 59cb8cfef..babd3b6d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,7 @@ RUN apt-get update -qq && \ ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" \ + BUNDLE_WITHOUT="development:test" \ LD_PRELOAD="/usr/local/lib/libjemalloc.so" # Throw-away build stage to reduce size of final image diff --git a/Gemfile.lock b/Gemfile.lock index e24a38a82..e4f364c1c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,7 +6,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: b22cb0c0cb39b103d816a66d560991acf0a57163 + revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0 branch: main specs: actioncable (8.2.0.alpha) @@ -216,7 +216,7 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.16.0) + json (2.17.1) jwt (3.1.2) base64 kamal (2.9.0) @@ -237,7 +237,7 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - lexxy (0.1.20.beta) + lexxy (0.1.23.beta) rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) @@ -267,7 +267,7 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails - mittens (0.3.0) + mittens (0.3.1) mocha (2.8.2) ruby2_keywords (>= 0.0.5) msgpack (1.8.0) @@ -351,7 +351,7 @@ GEM nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) rake (13.3.1) - rdoc (6.16.0) + rdoc (6.16.1) erb psych (>= 4.0.0) tsort @@ -443,7 +443,7 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.8) + stringio (3.1.9) thor (1.4.0) thruster (0.1.16) thruster (0.1.16-aarch64-linux) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index 5cd308155..f093ac677 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,7 +21,7 @@ GIT GIT remote: https://github.com/basecamp/fizzy-saas - revision: 97d126e0a6084905aceaadf4facaa2a48b44e124 + revision: 43dbc896ce7b6a08194a92ddd1695d3f1ebf554b specs: fizzy-saas (0.1.0) audits1984 @@ -75,7 +75,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: 690ec8898318b8f50714e86676353ebe1551261e + revision: 3d105fc346fbb3121bbcf6340f2b19104bf326f0 branch: main specs: actioncable (8.2.0.alpha) @@ -292,7 +292,7 @@ GEM actionview (>= 7.0.0) activesupport (>= 7.0.0) jmespath (1.6.2) - json (2.16.0) + json (2.17.1) jwt (3.1.2) base64 kamal (2.9.0) @@ -313,7 +313,7 @@ GEM logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - lexxy (0.1.20.beta) + lexxy (0.1.23.beta) rails (>= 8.0.2) lint_roller (1.1.0) logger (1.7.0) @@ -552,7 +552,7 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.8) + stringio (3.1.9) thor (1.4.0) thruster (0.1.16) thruster (0.1.16-aarch64-linux) diff --git a/README.md b/README.md index 8a7877326..07f55614e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,99 @@ This is the source code of [Fizzy](https://fizzy.do/), the Kanban tracking tool for issues and ideas by [37signals](https://37signals.com). +## Deploying Fizzy + +If you'd like to run Fizzy on your own server, we recommend deploying it with [Kamal](https://kamal-deploy.org/). +Kamal makes it easier to set up a bare server, copy the application to it, and manage the configuration settings that it uses. + +(Kamal is also what we use to deploy Fizzy at 37signals. If you're curious about what our deployment configuration looks like, you can find it inside [`fizzy-saas`](https://github.com/basecamp/fizzy-saas).) + +This repo contains a starter deployment file that you can modify for your own specific use. That file lives at [config/deploy.yml](config/deploy.yml), which is the default place where Kamal will look for it. + +The steps to configure your very own Fizzy are: + +1. Fork the repo +2. Edit few things in config/deploy.yml, .kamal/secrets, and config/environments/production.rb +3. Run `kamal setup` to do your first deploy. + +We'll go through each of these in turn. + +### Fork the repo + +To make it easy to customise Fizzy's settings for your own instance, you should start by creating your own GitHub fork of the repo. +That allows you to commit your changes, and track them over time. +You can always re-sync your fork to pick up new changes from the main repo over time. + +Once you've got your fork ready, run `bin/setup` from within it, to make sure everything is installed. + +### Editing the configuration + +The config/deploy.yml has been mostly set up for you, but you'll need to fill out some sections that are specific to your instance. +To get started, the parts you need to change are all in the "About your deployment" section. +We've added comments to that file to highlight what each setting needs to be, but the main ones are: + +- `servers/web`: Enter the hostname of the server you're deploying to here. This should be an address that you can access via `ssh`. +- `ssh/user`: If you access your server a `root` you can leave this alone; if you use a different user, set it here. +- `proxy/ssl` and `proxy/host`: Kamal can set up SSL certificates for you automatically. To enable that, set the hostname again as `host`. If you don't want SSL for some reason, you can set `ssl: false` to turn it off. +- `env/clear/MAILER_FROM_ADDRESS`: This is the email address that Fizzy will send emails from. It should usually be an address from the same domain where you're running Fizzy. + +Fizzy also requires a few environment variables to be set up, some of which contain secrets. +The simplest way to do this is to put them in a file called `.kamal/secrets`. +Because this file will contain secret credentials, it's important that you DON'T CHECK THIS FILE INTO YOUR REPO! You can add the filename to `.gitignore` to ensure you don't commit this file accidentally. + +If you use a password manager like 1Password, you can also opt to keep your secrets there instead. +Refer to the [Kamal documentation](https://kamal-deploy.org/docs/configuration/environment-variables/#secrets) for more information about how to do that. + +To store your secrets, create the file `.kamal/secrets` and enter something like the following: + +```ini +SECRET_KEY_BASE=12345 +VAPID_PUBLIC_KEY=something +VAPID_PRIVATE_KEY=somethingelse +SMTP_USERNAME=email-provider-username +SMTP_PASSWORD=email-provider-password +``` + +The values you enter here will be specific to you, and you can get or create them as follows: + +- `SECRET_KEY_BASE` should be a long, random secret. You can run `bin/rails secret` to create a suitable value for this. +- `VAPID_PUBLIC_KEY` & `VAPID_PRIVATE_KEY` are a pair of credentials that are used for sending notifications. You can create your own keys by starting a development console with: + + ```sh + bin/rails c + ``` + + And then run the following to create a new pair of keys: + + ```ruby + vapid_key = WebPush.generate_key + + puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" + puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" + ``` + +- `SMTP_USERNAME`/`SMTP_PASSWORD` are credentials you should get from your email provider. + +Lastly, you'll need to set up the rest of your email configuration in `config/environments/production.rb`. There is an example configuration in comments at the top of that file. The actual settings you use here will depend on your email provider, but in most cases will look similar to that section, so you can uncomment it and edit to suit. Note that it will use the `SMTP_USERNAME` and `SMTP_PASSWORD` values you entered in your secrets. + +Once you've made all those changes, commit them to your fork so they're saved. + +### Deploy Fizzy! + +You can now do your first deploy by running: + +```sh +bin/kamal setup +``` + +This will set up Docker (if needed), build your Fizzy app container, configure it, and start it running. + +After the first deploy is done, any subsequent steps won't need to do that initial setup. So for future deploys you can just run: + +```sh +bin/kamal deploy +``` + ## Development ### Setting up @@ -23,15 +116,35 @@ You'll be able to access the app in development at http://fizzy.localhost:3006. To login, enter `david@example.com` and grab the verification code from the browser console to sign in. +### Web Push Notifications + +Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. For notifications to work in development you'll need to generate a key pair and set these environment variables: + +- `VAPID_PRIVATE_KEY` +- `VAPID_PUBLIC_KEY` + +Generate them with the `web-push` gem: + +```ruby +vapid_key = WebPush.generate_key + +puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" +puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" +``` + ### Running tests For fast feedback loops, unit tests can be run with: - bin/rails test +```sh +bin/rails test +``` The full continuous integration tests can be run with: - bin/ci +```sh +bin/ci +``` ### Database configuration @@ -50,37 +163,18 @@ You can view email previews at http://fizzy.localhost:3006/rails/mailers. You can enable or disable [`letter_opener`](https://github.com/ryanb/letter_opener) to open sent emails automatically with: - bin/rails dev:email +```sh +bin/rails dev:email +``` Under the hood, this will create or remove `tmp/email-dev.txt`. -## Deployment - -We recommend [Kamal](https://kamal-deploy.org/) for deploying Fizzy. This project comes with a vanilla Rails template. You can find our production setup in [`fizzy-saas`](https://github.com/basecamp/fizzy-saas). - -### Web Push Notifications - -Fizzy uses VAPID (Voluntary Application Server Identification) keys to send browser push notifications. You'll need to generate a key pair and set these environment variables: - -- `VAPID_PRIVATE_KEY` -- `VAPID_PUBLIC_KEY` - -Generate them with the `web-push` gem: - -```ruby -vapid_key = WebPush.generate_key - -puts "VAPID_PRIVATE_KEY=#{vapid_key.private_key}" -puts "VAPID_PUBLIC_KEY=#{vapid_key.public_key}" -``` - ## SaaS gem 37signals bundles Fizzy with [`fizzy-saas`](https://github.com/basecamp/fizzy-saas), a companion gem that links Fizzy with our billing system and contains our production setup. This gem depends on some private git repositories and it is not meant to be used by third parties. But we hope it can serve as inspiration for anyone wanting to run fizzy on their own infrastructure. - ## Contributing We welcome contributions! Please read our [style guide](STYLE.md) before submitting code. @@ -88,4 +182,3 @@ We welcome contributions! Please read our [style guide](STYLE.md) before submitt ## License Fizzy is released under the [O'Saasy License](LICENSE.md). - diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css index 865be918c..9e4a9f8bf 100644 --- a/app/assets/stylesheets/_global.css +++ b/app/assets/stylesheets/_global.css @@ -10,7 +10,7 @@ --block-space-double: calc(var(--block-space) * 2); /* Text */ - --font-sans: system-ui; + --font-sans: -apple-system, BlinkMacSystemFont, sans-serif; --font-serif: ui-serif, serif; --font-mono: ui-monospace, monospace; @@ -219,6 +219,27 @@ --color-card-7: oklch(var(--lch-purple-medium)); --color-card-8: oklch(var(--lch-pink-medium)); + /* Colors: Highlighter */ + --highlight-1: rgb(136, 118, 38); + --highlight-2: rgb(185, 94, 6); + --highlight-3: rgb(207, 0, 0); + --highlight-4: rgb(216, 28, 170); + --highlight-5: rgb(144, 19, 254); + --highlight-6: rgb(5, 98, 185); + --highlight-7: rgb(17, 138, 15); + --highlight-8: rgb(148, 82, 22); + --highlight-9: rgb(102, 102, 102); + + --highlight-bg-1: rgba(229, 223, 6, 0.3); + --highlight-bg-2: rgba(255, 185, 87, 0.3); + --highlight-bg-3: rgba(255, 118, 118, 0.3); + --highlight-bg-4: rgba(248, 137, 216, 0.3); + --highlight-bg-5: rgba(190, 165, 255, 0.3); + --highlight-bg-6: rgba(124, 192, 252, 0.3); + --highlight-bg-7: rgba(140, 255, 129, 0.3); + --highlight-bg-8: rgba(221, 170, 123, 0.3); + --highlight-bg-9: rgba(200, 200, 200, 0.3); + /* Colors: Syntax highlighting */ --color-code-token__att: oklch(var(--lch-blue-dark)); --color-code-token__comment: oklch(var(--lch-ink-medium)); diff --git a/app/assets/stylesheets/animation.css b/app/assets/stylesheets/animation.css index 8c617a1d7..d6f05efa9 100644 --- a/app/assets/stylesheets/animation.css +++ b/app/assets/stylesheets/animation.css @@ -44,6 +44,11 @@ to { transform: translateY(0); } } + @keyframes slide-up-fade-in { + from { transform: translateY(2rem); opacity: 0; } + to { transform: translateY(0); opacity: 1; } + } + @keyframes slide-down { from { transform: translateY(0); } to { transform: translateY(2rem); } diff --git a/app/assets/stylesheets/blank-slates.css b/app/assets/stylesheets/blank-slates.css index 188f4926b..0725dd336 100644 --- a/app/assets/stylesheets/blank-slates.css +++ b/app/assets/stylesheets/blank-slates.css @@ -39,4 +39,8 @@ display: none; } } + + .cards__list:has(> :not(.blank-slate)) .card--hide-unless-empty { + display: none; + } } diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 150dde9a1..3132d886f 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -127,7 +127,7 @@ } @media (max-width: 799px) { - --padding-inline: var(--main-padding); + --padding-inline: 1.5ch; column-gap: 0; grid-template-areas: @@ -150,6 +150,10 @@ border-radius: 0.2em; grid-area: card; padding: clamp(2rem, 4vw, var(--padding-block)); + + @media (max-width: 799px) { + padding-inline: var(--padding-inline); + } } .card-perma__actions { diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index a0100ca1b..cc79ca76a 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -1,61 +1,39 @@ @layer components { + /* Editor + /* ------------------------------------------------------------------------ */ + lexxy-editor { display: block; position: relative; overflow: visible; figure.node--selected { - &:not(:has(img)) { - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } - &:has(img) { img { outline: var(--focus-ring-size) solid var(--focus-ring-color); outline-offset: var(--focus-ring-offset); } } - } - /* Lexical still uses the `lexxy-editor--empty` class if you have a list - started with no other characters. Here, we won't show the placeholder if - you've clicked the List button in the toolbar. */ - &.lexxy-editor--empty { - .lexxy-editor__content:not(:has(ul, ol))::before { - content: attr(placeholder); - color: currentColor; - cursor: text; - opacity: 0.66; - pointer-events: none; - position: absolute; - white-space: pre-line; + &:not(:has(img)) { + outline: var(--focus-ring-size) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); } } } - .lexxy-dialog-actions { - display: flex; - font-size: var(--text-x-small); - flex: 1 1 0%; - gap: var(--inline-space-half); - margin-block-start: var(--block-space-half); - - .btn { - --radius: 0.3em; - - inline-size: 100%; - justify-content: center; - - &:is([type="submit"]) { - --btn-background: var(--card-color, var(--color-link)); - --btn-color: var(--color-ink-inverted); - --focus-ring-color: var(--card-color, var(--color-link)); - } - } - - span { - inline-size: 100%; + /* Lexical uses the `lexxy-editor--empty` class even if you have a list + * started but haven't added other characters. Here, we hide the placeholder + * when you click the List button in the toolbar. */ + .lexxy-editor--empty { + .lexxy-editor__content:not(:has(ul, ol))::before { + content: attr(placeholder); + color: currentColor; + cursor: text; + opacity: 0.66; + pointer-events: none; + position: absolute; + white-space: pre-line; } } @@ -64,14 +42,9 @@ min-block-size: calc(7lh + var(--block-space)); outline: 0; - * { - &:first-child { - margin-block-start: 0; - } - - &:last-child { - margin-block-end: 0; - } + /* Allow color highlights to show through a bit */ + ::selection { + background: oklch(var(--lch-blue-light) / 0.5); } } @@ -81,6 +54,40 @@ outline: 2px dashed var(--color-selected-dark); } + .lexxy-code-language-picker { + -webkit-appearance: none; + appearance: none; + background-color: var(--color-canvas); + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); + background-position: center right 0.9em; + background-repeat: no-repeat; + background-size: 0.5em; + border: 1px solid var(--color-ink-light); + border-radius: 99rem; + color: var(--color-ink); + cursor: pointer; + font-family: var(--font-base); + font-size: var(--text-x-small); + font-weight: 500; + inset-inline-end: 0; + line-height: 1.15lh; + margin: 0.75ch 0.75ch 0 0; + padding-inline: 1.5ch 1.8em; + text-align: start; + + @media (prefers-color-scheme: dark) { + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); + } + + option { + background-color: var(--color-canvas); + color: var(--color-ink); + } + } + + /* Toolbar + /* ------------------------------------------------------------------------ */ + lexxy-toolbar { --lexxy-toolbar-icon-size: 1em; @@ -96,21 +103,6 @@ position: sticky; inset-block-start: 0; z-index: 1; - - dialog { - background-color: var(--color-canvas); - border: 1px solid var(--color-ink-lighter); - border-radius: 0.5em; - box-shadow: var(--shadow); - color: var(--color-ink); - padding: var(--block-space) calc(var(--inline-space) * 1.5); - position: absolute; - z-index: 1; - - .input[type="url"] { - min-inline-size: 30ch; - } - } } .lexxy-editor__toolbar-button { @@ -157,55 +149,130 @@ border-radius: 0.5ch; box-shadow: var(--shadow); display: flex; + flex-wrap: wrap; inset-inline-end: 0; padding: 4px; position: absolute; z-index: 1; } - :where(.lexxy-editor__toolbar-spacer) { + .lexxy-editor__toolbar-spacer { flex: 1; } - /* Based on .input, .input--select */ - .lexxy-code-language-picker { - -webkit-appearance: none; - appearance: none; + /* Dropdowns + /* ------------------------------------------------------------------------ */ + + .lexxy-editor__toolbar-dropdown-content { + --lexxy-dropdown-padding: 0.75rem; + --lexxy-dropdown-btn-size: 2rem; + background-color: var(--color-canvas); - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E"); - background-position: center right 0.9em; - background-repeat: no-repeat; - background-size: 0.5em; border: 1px solid var(--color-ink-lighter); - border-radius: 0.3em; + border-radius: 0.5em; + box-shadow: var(--shadow); color: var(--color-ink); - font-family: var(--font-base); - font-size: var(--text-x-small); - font-weight: 500; - inset-inline-end: 0; - line-height: inherit; - margin: 0.3em 0.3em 0 0; - padding: 0.5em 1.8em 0.5em 1.2em; - text-align: start; + font-size: var(--text-small); + padding: var(--lexxy-dropdown-padding); + position: absolute; + z-index: 1; - html[data-theme="dark"] & { - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); - } - - @media (prefers-color-scheme: dark) { - html:not([data-theme]) & { - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E"); - } - } - - - option { - background-color: var(--color-canvas); - color: var(--color-ink); + button { + block-size: var(--lexxy-dropdown-btn-size); } } - /* Prompt + .lexxy-editor__toolbar-dropdown-actions { + display: flex; + font-size: var(--text-x-small); + flex: 1 1 0%; + gap: var(--lexxy-dropdown-padding); + margin-block-start: var(--lexxy-dropdown-padding); + + .btn { + --radius: 99rem; + + inline-size: 100%; + } + + .btn[type="submit"] { + --btn-background: var(--card-color, var(--color-link)); + --btn-color: var(--color-ink-inverted); + --focus-ring-color: var(--card-color, var(--color-link)); + } + + span { + inline-size: 100%; + } + } + + lexxy-link-dropdown { + .input { + min-inline-size: 30ch; + } + } + + lexxy-highlight-dropdown { + --gap: 0.5ch; + + [data-button-group] { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: var(--gap); + + + & { + margin-block-start: var(--gap); + } + } + } + + .lexxy-highlight-button { + --outline-color: oklch(var(--lch-ink-darkest) / 0.2); + + appearance: none; + background: var(--color-canvas); + border: none; + border-radius: 0.5ch; + color: inherit; + display: grid; + font-weight: 500; + inline-size: var(--lexxy-dropdown-btn-size); + place-content: center; + position: relative; + outline: none; + + &:after { + content: "Aa"; + } + + &:hover, + &[aria-pressed="true"] { + box-shadow: 0 0 0 1px var(--color-canvas), 0 0 0 3px var(--outline-color); + } + + &[aria-pressed="true"] { + --outline-color: var(--color-link); + + &:after { + content: "✓"; + } + } + } + + .lexxy-editor__toolbar-dropdown-reset { + background: var(--color-canvas); + border: 1px solid var(--color-ink-light); + border-radius: 99rem; + inline-size: 100%; + margin-block-start: var(--lexxy-dropdown-padding); + + &[disabled] { + display: none; + } + } + + /* Prompt ,enu (@mentions, etc.) /* ------------------------------------------------------------------------ */ .lexxy-prompt-menu { diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index bcc60c85e..d7a12ed68 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -184,6 +184,7 @@ aspect-ratio: 5/3; background-color: var(--color-ink-lightest); border-radius: 0.5em; + container-type: inline-size; flex-basis: calc((100% - var(--gap) * 2) / 3); flex-direction: column; font-size: var(--text-small); @@ -218,6 +219,7 @@ @media (max-width: 639px) { font-size: var(--text-x-small); + font-size: clamp(var(--text-xx-small), 3.15cqi, var(--text-small)); font-weight: 500; } } diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index 06add9439..0213d04cb 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -10,8 +10,8 @@ inset: 0 auto auto 50%; 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))); + max-inline-size: min(55ch, calc(100vw - (var(--panel-padding)))); + min-inline-size: min(25ch, calc(100vw - (var(--panel-padding)))); overflow: auto; position: absolute; transform: translateX(-50%); diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 2d465c935..c9877f4bf 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -28,6 +28,19 @@ } } + :where(ol, ul) { + padding-inline-start: 3ch; + } + + :where(li:has(li)) { + list-style: none; + + :where(ol, ul) { + margin: 0; + padding-inline-start: 2ch; + } + } + :where(b, strong, .lexxy-content__bold) { font-weight: 700; } @@ -40,6 +53,10 @@ text-decoration: line-through; } + :where(mark, .lexxy-content__highlight) { + background-color: transparent; + color: inherit; + } :where(p, blockquote) { letter-spacing: -0.005ch; @@ -201,14 +218,17 @@ color: color-mix(in oklch, var(--color-ink) 66%, transparent); font-size: var(--text-small); - .input { + textarea { --input-border-radius: 0.3em; --input-border-size: 0; --input-padding: 0; + background-color: var(--input-background, transparent); + border: none; color: inherit; inline-size: 100%; max-inline-size: 100%; + resize: none; text-align: center; &:focus { @@ -274,7 +294,8 @@ display: flex; flex-wrap: wrap; gap: 1ch; - inline-size: auto; + inline-size: 100%; + margin-inline: 0; .attachment__caption { display: grid; diff --git a/app/controllers/boards/columns/not_nows_controller.rb b/app/controllers/boards/columns/not_nows_controller.rb index 6cb765979..0ff41e9ae 100644 --- a/app/controllers/boards/columns/not_nows_controller.rb +++ b/app/controllers/boards/columns/not_nows_controller.rb @@ -2,7 +2,7 @@ class Boards::Columns::NotNowsController < ApplicationController include BoardScoped def show - set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first.preloaded + set_page_and_extract_portion_from @board.cards.postponed.latest.preloaded fresh_when etag: @page.records end end diff --git a/app/controllers/cards/closures_controller.rb b/app/controllers/cards/closures_controller.rb index b89395cd5..b23600d91 100644 --- a/app/controllers/cards/closures_controller.rb +++ b/app/controllers/cards/closures_controller.rb @@ -2,12 +2,12 @@ class Cards::ClosuresController < ApplicationController include CardScoped def create - @card.close(user: Current.user) + @card.close render_card_replacement end def destroy - @card.reopen(user: Current.user) + @card.reopen render_card_replacement end end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 89eda5887..4cdbbdb69 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -97,6 +97,12 @@ module Authentication end end + def redirect_to_session_magic_link(magic_link, return_to: nil) + serve_development_magic_link(magic_link) + session[:return_to_after_authenticating] = return_to if return_to + redirect_to session_magic_link_url(script_name: nil) + end + def serve_development_magic_link(magic_link) if Rails.env.development? flash[:magic_link_code] = magic_link&.code diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index 971b3c6f8..8bd67c9dc 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -3,6 +3,7 @@ class JoinCodesController < ApplicationController before_action :set_join_code before_action :ensure_join_code_is_valid + before_action :set_identity, only: :create layout "public" @@ -10,22 +11,35 @@ class JoinCodesController < ApplicationController end def create - identity = Identity.find_or_create_by!(email_address: params.expect(:email_address)) + @join_code.redeem_if { |account| @identity.join(account) } + user = User.active.find_by!(account: @join_code.account, identity: @identity) - @join_code.redeem_if { |account| identity.join(account) } - user = User.active.find_by!(account: @join_code.account, identity: identity) - - if identity == Current.identity && user.setup? + if @identity == Current.identity && user.setup? redirect_to landing_url(script_name: @join_code.account.slug) - elsif identity == Current.identity - redirect_to new_users_join_url(script_name: @join_code.account.slug) + elsif @identity == Current.identity + redirect_to new_users_verification_url(script_name: @join_code.account.slug) else - logout_and_send_new_magic_link(identity) - redirect_to session_magic_link_url(script_name: nil) + terminate_session if Current.identity + + redirect_to_session_magic_link \ + @identity.send_magic_link, + return_to: new_users_verification_url(script_name: @join_code.account.slug) end end private + def set_identity + @identity = Identity.find_or_initialize_by(email_address: params.expect(:email_address)) + + if @identity.new_record? + if @identity.invalid? + head :unprocessable_entity + else + @identity.save! + end + end + end + def set_join_code @join_code ||= Account::JoinCode.find_by(code: params.expect(:code), account: Current.account) end @@ -37,13 +51,4 @@ class JoinCodesController < ApplicationController render :inactive, status: :gone end end - - def logout_and_send_new_magic_link(identity) - terminate_session if Current.identity - - magic_link = identity.send_magic_link - serve_development_magic_link(magic_link) - - session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug) - end end diff --git a/app/controllers/public/boards/columns/not_nows_controller.rb b/app/controllers/public/boards/columns/not_nows_controller.rb index d518fb8e0..5a54df171 100644 --- a/app/controllers/public/boards/columns/not_nows_controller.rb +++ b/app/controllers/public/boards/columns/not_nows_controller.rb @@ -1,5 +1,5 @@ class Public::Boards::Columns::NotNowsController < Public::BaseController def show - set_page_and_extract_portion_from @board.cards.postponed.reverse_chronologically.with_golden_first + set_page_and_extract_portion_from @board.cards.postponed.latest end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 5335f58f3..fb9fe0617 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -9,17 +9,16 @@ class SessionsController < ApplicationController end def create - identity = Identity.find_by_email_address(email_address) - - magic_link = if identity - identity.send_magic_link + if identity = Identity.find_by_email_address(email_address) + redirect_to_session_magic_link identity.send_magic_link else - Signup.new(email_address: email_address).create_identity + signup = Signup.new(email_address: email_address) + if signup.valid?(:identity_creation) + redirect_to_session_magic_link signup.create_identity + else + head :unprocessable_entity + end end - - serve_development_magic_link(magic_link) - - redirect_to session_magic_link_path end def destroy diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index 33da02f28..776421201 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -11,8 +11,12 @@ class SignupsController < ApplicationController end def create - Signup.new(signup_params).create_identity - redirect_to session_magic_link_path + signup = Signup.new(signup_params) + if signup.valid?(:identity_creation) + redirect_to_session_magic_link signup.create_identity + else + head :unprocessable_entity + end end private diff --git a/app/controllers/users/verifications_controller.rb b/app/controllers/users/verifications_controller.rb new file mode 100644 index 000000000..d99492576 --- /dev/null +++ b/app/controllers/users/verifications_controller.rb @@ -0,0 +1,11 @@ +class Users::VerificationsController < ApplicationController + layout "public" + + def new + end + + def create + Current.user.verify + redirect_to new_users_join_path + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ae800c75d..15f8ecd6f 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -9,8 +9,11 @@ class UsersController < ApplicationController end def update - @user.update! user_params - redirect_to @user + if @user.update(user_params) + redirect_to @user + else + render :edit, status: :unprocessable_entity + end end def destroy diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb index 2d1b5522e..69ef2efec 100644 --- a/app/helpers/accesses_helper.rb +++ b/app/helpers/accesses_helper.rb @@ -1,6 +1,4 @@ module AccessesHelper - MAX_DISPLAYED_WATCHERS = 8 - def access_menu_tag(board, **options, &) tag.menu class: [ options[:class], { "toggler--toggled": board.all_access? } ], data: { controller: "filter toggle-class navigable-list", @@ -14,7 +12,7 @@ module AccessesHelper render partial: "boards/access_toggle", collection: users, as: :user, locals: { selected: selected, disabled: disabled }, - cached: ->(user) { [ user, selected ] } + cached: ->(user) { [ user, selected, disabled ] } end def access_involvement_advance_button(board, user, show_watchers: true, icon_only: false) @@ -27,45 +25,30 @@ module AccessesHelper end def board_watchers_list(board) - watchers = board.watchers.with_avatars + watchers = board.watchers.with_avatars.load - displayed_watchers = watchers.limit(MAX_DISPLAYED_WATCHERS) - overflow_count = watchers.count - MAX_DISPLAYED_WATCHERS + displayed_watchers = watchers.first(8) + overflow_count = watchers.size - 8 - safe_join([ - tag.strong(displayed_watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase"), - tag.div(class: "board-tools__watching") do - safe_join([ - safe_join(displayed_watchers.map { |watcher| avatar_tag(watcher) }), - (tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do - concat tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" }) - concat(tag.dialog(class: "board-tools__watching-dialog dialog panel", data: { dialog_target: "dialog" }, aria: { hidden: "true" }) do - safe_join(watchers.map { |watcher| avatar_tag(watcher) }) - end) - end if overflow_count > 0) - ].compact) - end - ]) + tag.strong(watchers.any? ? "Watching for new cards" : "No one is watching for new cards", class: "txt-uppercase") + + tag.div(avatar_tags(displayed_watchers), class: "board-tools__watching") do + tag.div(data: { controller: "dialog", action: "keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" }) do + tag.button("+#{overflow_count}", class: "overflow-count btn btn--circle borderless", data: { action: "dialog#open" }, aria: { label: "Show #{overflow_count} more watchers" }) + + tag.dialog(avatar_tags(watchers), class: "board-tools__watching-dialog dialog panel", data: { dialog_target: "dialog" }, aria: { hidden: "true" }) + end if overflow_count > 0 + end end def involvement_button(board, access, show_watchers, icon_only) - involvement_label_id = dom_id(board, :involvement_label) - + label_text = access.access_only? ? "Watch this" : "Stop watching" button_to( - board_involvement_path(board), - method: :put, - aria: { labelledby: involvement_label_id }, - title: involvement_access_label(access), - class: class_names("btn", { "btn--reversed": access.watching? && icon_only }), - params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only } - ) do - safe_join([ - icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}"), - tag.span( - class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), - id: involvement_label_id - ) - ]) + board_involvement_path(board), method: :put, + params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only }, + aria: { labelledby: dom_id(board, :involvement_label) }, + title: (label_text if icon_only), + class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do + icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") + + tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label)) end end @@ -74,12 +57,4 @@ module AccessesHelper order = %w[ watching access_only ] order[(order.index(involvement.to_s) + 1) % order.size] end - - def involvement_access_label(access) - if access.access_only? - "Watch this" - else - "Stop watching" - end - end end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 8fb42448a..7c064a650 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -19,6 +19,10 @@ module AvatarsHelper end end + def avatar_tags(users, **options) + users.collect { avatar_tag(it, **options) }.join.html_safe + end + def mail_avatar_tag(user, size: 48, **options) if user.avatar.attached? image_tag user_avatar_url(user), alt: user.name, class: "avatar", size: size, **options diff --git a/app/helpers/cards_helper.rb b/app/helpers/cards_helper.rb index 0dd93c520..9b149b707 100644 --- a/app/helpers/cards_helper.rb +++ b/app/helpers/cards_helper.rb @@ -1,5 +1,5 @@ module CardsHelper - def card_article_tag(card, id: dom_id(card, :article), **options, &block) + def card_article_tag(card, id: dom_id(card, :article), data: {}, **options, &block) classes = [ options.delete(:class), ("golden-effect" if card.golden?), @@ -7,22 +7,17 @@ module CardsHelper ("card--active" if card.active?) ].compact.join(" ") + data[:drag_and_drop_top] = true if card.golden? && !card.closed? && !card.postponed? + tag.article \ id: id, style: "--card-color: #{card.color}; view-transition-name: #{id}", class: classes, + data: data, **options, &block end - def button_to_delete_card(card) - button_to card_path(card), - method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_frame: "_top", turbo_confirm: "Are you sure you want to permanently delete this card?" } do - concat(icon_tag("trash")) - concat(tag.span("Delete this card")) - end - end - def card_title_tag(card) title = [ card.title, diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index 8a2e82e17..222d41357 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -10,14 +10,16 @@ module ColumnsHelper data: { turbo_frame: "_top" } end - def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, data: {}, **properties, &block) + def column_tag(id:, name:, drop_url:, collapsed: true, selected: nil, card_color: "var(--color-card-default)", 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 + drag_and_drop_url: drop_url, + drag_and_drop_css_variable_name: "--card-color", + drag_and_drop_css_variable_value: card_color }.merge(data) data[:action] = token_list( @@ -28,7 +30,8 @@ module ColumnsHelper 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", + controller: "navigable-list css-variable-counter", + css_variable_counter_property_name_value: "--card-count", navigable_list_supports_horizontal_navigation_value: "false", navigable_list_prevent_handled_keys_value: "true", navigable_list_auto_select_value: "false", diff --git a/app/helpers/html_helper.rb b/app/helpers/html_helper.rb index 82a18722d..8998ffcb9 100644 --- a/app/helpers/html_helper.rb +++ b/app/helpers/html_helper.rb @@ -9,7 +9,7 @@ module HtmlHelper private EXCLUDED_ELEMENTS = %w[ a figcaption pre code ] - EMAIL_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/ + EMAIL_AUTOLINK_REGEXP = /\b[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\b/ URL_REGEXP = URI::DEFAULT_PARSER.make_regexp(%w[http https]) def auto_link(fragment) @@ -48,7 +48,7 @@ module HtmlHelper end def auto_link_emails(text) - text.gsub!(EMAIL_REGEXP) do |match| + text.gsub!(EMAIL_AUTOLINK_REGEXP) do |match| %(#{match}) end end diff --git a/app/javascript/controllers/back_navigation_controller.js b/app/javascript/controllers/back_navigation_controller.js deleted file mode 100644 index 8fad83419..000000000 --- a/app/javascript/controllers/back_navigation_controller.js +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static values = { fallbackDestination: String } - - connect() { - if (history.state.turbo?.restorationIndex > 0) { - this.element.href = "javascript:history.back()" - } else { - this.element.href = this.fallbackDestinationValue - } - } -} diff --git a/app/javascript/controllers/bubble_controller.js b/app/javascript/controllers/bubble_controller.js index 66fd469cf..714977c66 100644 --- a/app/javascript/controllers/bubble_controller.js +++ b/app/javascript/controllers/bubble_controller.js @@ -33,8 +33,7 @@ export default class extends Controller { } get #entropyCleanupInDays() { - this.entropyCleanupInDays ??= signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt)) - return this.entropyCleanupInDays + return signedDifferenceInDays(new Date(), new Date(this.entropyValue.closesAt)) } #showEntropy() { diff --git a/app/javascript/controllers/css_variable_counter_controller.js b/app/javascript/controllers/css_variable_counter_controller.js new file mode 100644 index 000000000..1fa2cd5b9 --- /dev/null +++ b/app/javascript/controllers/css_variable_counter_controller.js @@ -0,0 +1,33 @@ +import { Controller } from "@hotwired/stimulus" +import { debounce } from "helpers/timing_helpers" + +export default class extends Controller { + static targets = [ "item", "counter" ] + static values = { + propertyName: String, + maxValue: { type: Number, default: 20 } + } + + initialize() { + this.#updateCounter = debounce(this.#updateCounter.bind(this), 50) + } + + connect() { + this.#updateCounter() + } + + itemTargetConnected() { + this.#updateCounter() + } + + itemTargetDisconnected() { + this.#updateCounter() + } + + #updateCounter = () => { + if (!this.hasCounterTarget) return + + const count = Math.min(this.itemTargets.length, this.maxValueValue) + this.counterTarget.style.setProperty(this.propertyNameValue, count) + } +} diff --git a/app/javascript/controllers/drag_and_drop_controller.js b/app/javascript/controllers/drag_and_drop_controller.js index 5c03853be..5fc532ca1 100644 --- a/app/javascript/controllers/drag_and_drop_controller.js +++ b/app/javascript/controllers/drag_and_drop_controller.js @@ -16,11 +16,14 @@ export default class extends Controller { await nextFrame() this.dragItem = this.#itemContaining(event.target) this.sourceContainer = this.#containerContaining(this.dragItem) + this.originalDraggedItemCssVariable = this.#containerCssVariableFor(this.sourceContainer) this.dragItem.classList.add(this.draggedItemClass) } dragOver(event) { event.preventDefault() + if (!this.dragItem) { return } + const container = this.#containerContaining(event.target) this.#clearContainerHoverClasses() @@ -28,32 +31,39 @@ export default class extends Controller { if (container !== this.sourceContainer) { container.classList.add(this.hoverContainerClass) + this.#applyContainerCssVariableToDraggedItem(container) + } else { + this.#restoreOriginalDraggedItemCssVariable() } } async drop(event) { - const container = this.#containerContaining(event.target) + const targetContainer = this.#containerContaining(event.target) - if (!container || container === this.sourceContainer) { return } + if (!targetContainer || targetContainer === this.sourceContainer) { return } this.wasDropped = true + this.#increaseCounter(targetContainer) this.#decreaseCounter(this.sourceContainer) + const sourceContainer = this.sourceContainer - await this.#submitDropRequest(this.dragItem, container) - this.#reloadSourceFrame(sourceContainer); + this.#insertDraggedItem(targetContainer, this.dragItem) + await this.#submitDropRequest(this.dragItem, targetContainer) + this.#reloadSourceFrame(sourceContainer) } dragEnd() { this.dragItem.classList.remove(this.draggedItemClass) this.#clearContainerHoverClasses() - if (this.wasDropped) { - this.dragItem.remove() + if (!this.wasDropped) { + this.#restoreOriginalDraggedItemCssVariable() } this.sourceContainer = null this.dragItem = null this.wasDropped = false + this.originalDraggedItemCssVariable = null } #itemContaining(element) { @@ -68,6 +78,63 @@ export default class extends Controller { this.containerTargets.forEach(container => container.classList.remove(this.hoverContainerClass)) } + #applyContainerCssVariableToDraggedItem(container) { + const cssVariable = this.#containerCssVariableFor(container) + if (cssVariable) { + this.dragItem.style.setProperty(cssVariable.name, cssVariable.value) + } + } + + #restoreOriginalDraggedItemCssVariable() { + if (this.originalDraggedItemCssVariable) { + const { name, value } = this.originalDraggedItemCssVariable + this.dragItem.style.setProperty(name, value) + } + } + + #containerCssVariableFor(container) { + const { dragAndDropCssVariableName, dragAndDropCssVariableValue } = container.dataset + if (dragAndDropCssVariableName && dragAndDropCssVariableValue) { + return { name: dragAndDropCssVariableName, value: dragAndDropCssVariableValue } + } + return null + } + + #increaseCounter(container) { + this.#modifyCounter(container, count => count + 1) + } + + #decreaseCounter(container) { + this.#modifyCounter(container, count => Math.max(0, count - 1)) + } + + #modifyCounter(container, fn) { + const counterElement = container.querySelector("[data-drag-and-drop-counter]") + if (counterElement) { + const currentValue = counterElement.textContent.trim() + + if (!/^\d+$/.test(currentValue)) return + + counterElement.textContent = fn(parseInt(currentValue)) + } + } + + #insertDraggedItem(container, item) { + const itemContainer = container.querySelector("[data-drag-drop-item-container]") + const topItems = itemContainer.querySelectorAll("[data-drag-and-drop-top]") + const firstTopItem = topItems[0] + const lastTopItem = topItems[topItems.length - 1] + + const isTopItem = item.hasAttribute("data-drag-and-drop-top") + const referenceItem = isTopItem ? firstTopItem : lastTopItem + + if (referenceItem) { + referenceItem[isTopItem ? "before" : "after"](item) + } else { + itemContainer.prepend(item) + } + } + async #submitDropRequest(item, container) { const body = new FormData() const id = item.dataset.id @@ -80,18 +147,4 @@ export default class extends Controller { const frame = sourceContainer.querySelector("[data-drag-and-drop-refresh]") if (frame) frame.reload() } - - #decreaseCounter(sourceContainer) { - const counterElement = sourceContainer.querySelector("[data-drag-and-drop-counter]") - if (counterElement) { - const currentValue = counterElement.textContent.trim() - - if (!/^\d+$/.test(currentValue)) return - - const count = parseInt(currentValue) - if (count > 0) { - counterElement.textContent = count - 1 - } - } - } } diff --git a/app/javascript/controllers/form_controller.js b/app/javascript/controllers/form_controller.js index e910d6622..beca1f08f 100644 --- a/app/javascript/controllers/form_controller.js +++ b/app/javascript/controllers/form_controller.js @@ -8,10 +8,21 @@ export default class extends Controller { debounceTimeout: { type: Number, default: 300 } } + #isComposing = false + initialize() { this.debouncedSubmit = debounce(this.debouncedSubmit.bind(this), this.debounceTimeoutValue) } + // IME Composition tracking + compositionStart() { + this.#isComposing = true + } + + compositionEnd() { + this.#isComposing = false + } + submit() { this.element.requestSubmit() } @@ -21,12 +32,23 @@ export default class extends Controller { if (input) { const value = (input.value || "").trim() - if (value.length === 0) { + const isEmpty = value.length === 0 + + if (isEmpty) { event.preventDefault() + input.setCustomValidity(input.dataset.validationMessage || "Please fill out this field") + input.reportValidity() + input.addEventListener("input", () => input.setCustomValidity(""), { once: true }) } } } + preventComposingSubmit(event) { + if (this.#isComposing) { + event.preventDefault() + } + } + debouncedSubmit(event) { this.submit(event) } diff --git a/app/javascript/controllers/lightbox_controller.js b/app/javascript/controllers/lightbox_controller.js index b39566b44..e8bc48f03 100644 --- a/app/javascript/controllers/lightbox_controller.js +++ b/app/javascript/controllers/lightbox_controller.js @@ -3,14 +3,6 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { static targets = [ "caption", "image", "dialog", "zoomedImage" ] - connect() { - this.dialogTarget.addEventListener('transitionend', this.handleTransitionEnd.bind(this)) - } - - disconnect() { - this.dialogTarget.removeEventListener('transitionend', this.handleTransitionEnd.bind(this)) - } - open(event) { this.dialogTarget.showModal() this.#set(event.target.closest("a")) diff --git a/app/javascript/controllers/magic_link_controller.js b/app/javascript/controllers/magic_link_controller.js index 4d6c45ab5..3e2e4618c 100644 --- a/app/javascript/controllers/magic_link_controller.js +++ b/app/javascript/controllers/magic_link_controller.js @@ -4,12 +4,18 @@ import { onNextEventLoopTick } from "helpers/timing_helpers" export default class extends Controller { static targets = [ "input" ] + submitOnEnter(event) { + event.preventDefault() + this.submit() + } + + submitOnPaste() { + onNextEventLoopTick(() => this.submit()) + } + submit() { - onNextEventLoopTick(() => { - if (!this.inputTarget.disabled) { - this.element.submit() - this.inputTarget.disabled = true - } - }) + if (this.inputTarget.disabled) return + this.element.submit() + this.inputTarget.disabled = true } } diff --git a/app/javascript/controllers/navigable_list_controller.js b/app/javascript/controllers/navigable_list_controller.js index 4af794c4b..bd5e87665 100644 --- a/app/javascript/controllers/navigable_list_controller.js +++ b/app/javascript/controllers/navigable_list_controller.js @@ -245,6 +245,9 @@ export default class extends Controller { } }, Enter(event) { + // Skip handling during IME composition (e.g., Japanese input) + if (event.isComposing) { return } + if (event.shiftKey) { this.#toggleCurrentItem(event) } else { diff --git a/app/models/account.rb b/app/models/account.rb index 50f3bf3ee..61a636adb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -21,7 +21,7 @@ class Account < ApplicationRecord def create_with_owner(account:, owner:) create!(**account).tap do |account| account.users.create!(role: :system, name: "System") - account.users.create!(**owner.reverse_merge(role: "owner")) + account.users.create!(**owner.reverse_merge(role: "owner", verified_at: Time.current)) end end end diff --git a/app/models/card/broadcastable.rb b/app/models/card/broadcastable.rb index aa24b776c..9cf112200 100644 --- a/app/models/card/broadcastable.rb +++ b/app/models/card/broadcastable.rb @@ -3,5 +3,16 @@ module Card::Broadcastable included do broadcasts_refreshes + + before_update :remember_if_preview_changed end + + private + def remember_if_preview_changed + @preview_changed ||= title_changed? || column_id_changed? || board_id_changed? + end + + def preview_changed? + @preview_changed + end end diff --git a/app/models/card/eventable.rb b/app/models/card/eventable.rb index c52fb7a29..520fd95d4 100644 --- a/app/models/card/eventable.rb +++ b/app/models/card/eventable.rb @@ -19,7 +19,6 @@ module Card::Eventable def touch_last_active_at # Not using touch so that we can detect attribute change on callbacks update!(last_active_at: Time.current) - broadcast_activity end private @@ -36,8 +35,4 @@ module Card::Eventable def create_system_comment_for(event) SystemCommenter.new(self, event).comment end - - def broadcast_activity - broadcast_render_later_to self, :activity, partial: "card/display/refresh_activity", locals: { card: self } - end end diff --git a/app/models/card/pinnable.rb b/app/models/card/pinnable.rb index ff3407ea0..9929a785d 100644 --- a/app/models/card/pinnable.rb +++ b/app/models/card/pinnable.rb @@ -3,6 +3,8 @@ module Card::Pinnable included do has_many :pins, dependent: :destroy + + after_update_commit :broadcast_pin_updates, if: :preview_changed? end def pinned_by?(user) @@ -20,4 +22,11 @@ module Card::Pinnable def unpin_by(user) pins.find_by(user: user).tap { it.destroy } end + + private + def broadcast_pin_updates + pins.find_each do |pin| + pin.broadcast_replace_later_to [ pin.user, :pins_tray ], partial: "my/pins/pin" + end + end end diff --git a/app/models/concerns/attachments.rb b/app/models/concerns/attachments.rb index 1d1c2c4d6..bcc7303ae 100644 --- a/app/models/concerns/attachments.rb +++ b/app/models/concerns/attachments.rb @@ -1,13 +1,10 @@ module Attachments extend ActiveSupport::Concern - # The variants we use must all declared here so that we can preprocess them. + # Variants used by ActionText embeds. Processed immediately on attachment to avoid + # read replica issues (lazy variants would attempt writes on read replicas). # - # If they are not preprocessed, then Rails will attempt to transform the image on-the-fly when - # they are first viewed, which may be on the read replica where writing to the database is not - # allowed. Chaos will ensue if that happens. - # - # These variants are patched into ActionText::RichText in config/initializers/action_text.rb + # Patched into ActionText::RichText in config/initializers/action_text.rb VARIANTS = { # vipsthumbnail used to create sized image variants has a intent setting to manage colors during # resize. By setting an invalid intent value the gif-incompatible intent filtering is skipped and diff --git a/app/models/identity.rb b/app/models/identity.rb index 135fdaae7..bb69734b4 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -8,8 +8,9 @@ class Identity < ApplicationRecord has_one_attached :avatar - before_destroy :deactivate_users + before_destroy :deactivate_users, prepend: true + validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP } normalizes :email_address, with: ->(value) { value.strip.downcase.presence } def send_magic_link(**attributes) diff --git a/app/models/push/subscription.rb b/app/models/push/subscription.rb index 0b273b563..afd417367 100644 --- a/app/models/push/subscription.rb +++ b/app/models/push/subscription.rb @@ -1,14 +1,54 @@ class Push::Subscription < ApplicationRecord + PERMITTED_ENDPOINT_HOSTS = %w[ + fcm.googleapis.com + updates.push.services.mozilla.com + web.push.apple.com + notify.windows.com + ].freeze + belongs_to :account, default: -> { user.account } belongs_to :user + validates :endpoint, presence: true + validate :validate_endpoint_url + def notification(**params) WebPush::Notification.new( **params, badge: user.notifications.unread.count, endpoint: endpoint, + endpoint_ip: resolved_endpoint_ip, p256dh_key: p256dh_key, auth_key: auth_key ) end + + def resolved_endpoint_ip + return @resolved_endpoint_ip if defined?(@resolved_endpoint_ip) + @resolved_endpoint_ip = SsrfProtection.resolve_public_ip(endpoint_uri&.host) + end + + private + def endpoint_uri + @endpoint_uri ||= URI.parse(endpoint) if endpoint.present? + rescue URI::InvalidURIError + nil + end + + def validate_endpoint_url + if endpoint_uri.nil? + errors.add(:endpoint, "is not a valid URL") + elsif endpoint_uri.scheme != "https" + errors.add(:endpoint, "must use HTTPS") + elsif !permitted_endpoint_host? + errors.add(:endpoint, "is not a permitted push service") + elsif resolved_endpoint_ip.nil? + errors.add(:endpoint, "resolves to a private or invalid IP address") + end + end + + def permitted_endpoint_host? + host = endpoint_uri&.host&.downcase + PERMITTED_ENDPOINT_HOSTS.any? { |permitted| host&.end_with?(permitted) } + end end diff --git a/app/models/signup.rb b/app/models/signup.rb index 15ac5873c..110c253ef 100644 --- a/app/models/signup.rb +++ b/app/models/signup.rb @@ -6,9 +6,8 @@ class Signup attr_accessor :full_name, :email_address, :identity attr_reader :account, :user - with_options on: :completion do - validates_presence_of :full_name, :identity - end + validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation + validates :full_name, :identity, presence: true, on: :completion def initialize(...) super diff --git a/app/models/ssrf_protection.rb b/app/models/ssrf_protection.rb index f3df2511b..a73b6b3be 100644 --- a/app/models/ssrf_protection.rb +++ b/app/models/ssrf_protection.rb @@ -3,14 +3,21 @@ module SsrfProtection DNS_RESOLUTION_TIMEOUT = 2 + DNS_NAMESERVERS = %w[ + 1.1.1.1 + 8.8.8.8 + ] + DISALLOWED_IP_RANGES = [ - IPAddr.new("0.0.0.0/8") # Broadcasts + IPAddr.new("0.0.0.0/8"), # "This" network (RFC1700) + IPAddr.new("100.64.0.0/10"), # Carrier-grade NAT (RFC6598) + IPAddr.new("198.18.0.0/15") # Benchmark testing (RFC2544) ].freeze def resolve_public_ip(hostname) ip_addresses = resolve_dns(hostname) public_ips = ip_addresses.reject { |ip| private_address?(ip) } - public_ips.first&.to_s + public_ips.sort_by { |ipaddr| ipaddr.ipv4? ? 0 : 1 }.first&.to_s end def private_address?(ip) @@ -22,7 +29,7 @@ module SsrfProtection def resolve_dns(hostname) ip_addresses = [] - Resolv::DNS.open(timeouts: DNS_RESOLUTION_TIMEOUT) do |dns| + Resolv::DNS.open(nameserver: DNS_NAMESERVERS, timeouts: DNS_RESOLUTION_TIMEOUT) do |dns| dns.each_address(hostname) do |ip_address| ip_addresses << IPAddr.new(ip_address.to_s) end diff --git a/app/models/user.rb b/app/models/user.rb index 8c0ac42a5..20ee1c40c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -28,6 +28,14 @@ class User < ApplicationRecord name != identity.email_address end + def verified? + verified_at.present? + end + + def verify + update!(verified_at: Time.current) unless verified? + end + private def close_remote_connections ActionCable.server.remote_connections.where(current_user: self).disconnect(reconnect: false) diff --git a/app/models/user/avatar.rb b/app/models/user/avatar.rb index a4b2ae631..c635edddb 100644 --- a/app/models/user/avatar.rb +++ b/app/models/user/avatar.rb @@ -5,7 +5,7 @@ module User::Avatar included do has_one_attached :avatar do |attachable| - attachable.variant :thumb, resize_to_fill: [ 256, 256 ] + attachable.variant :thumb, resize_to_fill: [ 256, 256 ], process: :immediately end validate :avatar_content_type_allowed diff --git a/app/models/user/day_timeline.rb b/app/models/user/day_timeline.rb index 1fe4ed36e..4b30d24ca 100644 --- a/app/models/user/day_timeline.rb +++ b/app/models/user/day_timeline.rb @@ -30,15 +30,15 @@ class User::DayTimeline end def added_column - @added_column ||= build_column("Added", 1, events.where(action: %w[card_published card_reopened])) + @added_column ||= build_column(:added, "Added", 1, events.where(action: %w[card_published card_reopened])) end def updated_column - @updated_column ||= build_column("Updated", 2, events.where.not(action: %w[card_published card_closed card_reopened])) + @updated_column ||= build_column(:updated, "Updated", 2, events.where.not(action: %w[card_published card_closed card_reopened])) end def closed_column - @closed_column ||= build_column("Done", 3, events.where(action: "card_closed")) + @closed_column ||= build_column(:closed, "Done", 3, events.where(action: "card_closed")) end def cache_key @@ -84,8 +84,8 @@ class User::DayTimeline filtered_events.where(created_at: ...day.beginning_of_day).chronologically.last end - def build_column(base_title, index, events) - Column.new(self, base_title, index, events) + def build_column(id, base_title, index, events) + Column.new(self, id, base_title, index, events) end def window diff --git a/app/models/user/day_timeline/column.rb b/app/models/user/day_timeline/column.rb index ce87873fa..0f9261bbb 100644 --- a/app/models/user/day_timeline/column.rb +++ b/app/models/user/day_timeline/column.rb @@ -1,9 +1,10 @@ class User::DayTimeline::Column include ActionView::Helpers::TagHelper, ActionView::Helpers::OutputSafetyHelper, TimeHelper - attr_reader :index, :base_title, :day_timeline, :events + attr_reader :index, :id, :base_title, :day_timeline, :events - def initialize(day_timeline, base_title, index, events) + def initialize(day_timeline, id, base_title, index, events) + @id = id @day_timeline = day_timeline @base_title = base_title @index = index @@ -17,15 +18,6 @@ class User::DayTimeline::Column safe_join(parts, " ") end - def base_title_for_route - case base_title - when "Done" - "closed" - else - base_title.downcase - end - end - def events_by_hour limited_events.group_by { it.created_at.hour } end @@ -38,6 +30,10 @@ class User::DayTimeline::Column full_events_count - limited_events.count end + def to_param + id + end + private def limited_events @limited_events ||= events.limit(100).load diff --git a/app/models/user/settings.rb b/app/models/user/settings.rb index 1651e9a64..598ede9c5 100644 --- a/app/models/user/settings.rb +++ b/app/models/user/settings.rb @@ -21,7 +21,7 @@ class User::Settings < ApplicationRecord end def bundling_emails? - !bundle_email_never? && !user.system? && user.active? + !bundle_email_never? && !user.system? && user.active? && user.verified? end def timezone diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index b635baac5..c5dab1497 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -78,7 +78,8 @@ class Webhook::Delivery < ApplicationRecord end def http - Net::HTTP.new(uri.host, uri.port, ipaddr: resolved_ip).tap do |http| + Net::HTTP.new(uri.host, uri.port).tap do |http| + http.ipaddr = resolved_ip http.use_ssl = (uri.scheme == "https") http.open_timeout = ENDPOINT_TIMEOUT http.read_timeout = ENDPOINT_TIMEOUT diff --git a/app/views/boards/_access_toggle.erb b/app/views/boards/_access_toggle.html.erb similarity index 100% rename from app/views/boards/_access_toggle.erb rename to app/views/boards/_access_toggle.html.erb diff --git a/app/views/boards/columns/_empty_placeholder.html.erb b/app/views/boards/columns/_empty_placeholder.html.erb new file mode 100644 index 000000000..36507c1ff --- /dev/null +++ b/app/views/boards/columns/_empty_placeholder.html.erb @@ -0,0 +1,6 @@ +
Drag cards here
-Drag cards here
-Drag cards here
-