Merge branch 'main' into theme-switcher-revised

This commit is contained in:
Jason Zimdars
2025-12-08 17:58:46 -06:00
committed by GitHub
129 changed files with 1708 additions and 564 deletions
+6
View File
@@ -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
+3
View File
@@ -3,6 +3,9 @@ name: Checks
on:
pull_request:
permissions:
contents: read
jobs:
security:
name: Security
+3
View File
@@ -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
+3
View File
@@ -3,6 +3,9 @@ name: CI (SaaS)
on:
push:
permissions:
contents: read
jobs:
test_oss:
name: Test (OSS)
+3
View File
@@ -10,6 +10,9 @@ on:
GH_TOKEN:
required: false
permissions:
contents: read
jobs:
test:
name: Tests (${{ matrix.mode }})
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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)
+5 -5
View File
@@ -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)
+118 -25
View File
@@ -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).
+22 -1
View File
@@ -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));
+5
View File
@@ -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); }
+4
View File
@@ -39,4 +39,8 @@
display: none;
}
}
.cards__list:has(> :not(.blank-slate)) .card--hide-unless-empty {
display: none;
}
}
+5 -1
View File
@@ -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 {
+163 -96
View File
@@ -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 {
+2
View File
@@ -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;
}
}
+2 -2
View File
@@ -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%);
+23 -2
View File
@@ -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;
@@ -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
+2 -2
View File
@@ -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
@@ -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
+23 -18
View File
@@ -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
@@ -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
+8 -9
View File
@@ -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
+6 -2
View File
@@ -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
@@ -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
+5 -2
View File
@@ -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
+19 -44
View File
@@ -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
+4
View File
@@ -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
+4 -9
View File
@@ -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,
+6 -3
View File
@@ -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",
+2 -2
View File
@@ -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|
%(<a href="mailto:#{match}">#{match}</a>)
end
end
@@ -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
}
}
}
@@ -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() {
@@ -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)
}
}
@@ -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
}
}
}
}
+23 -1
View File
@@ -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)
}
@@ -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"))
@@ -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
}
}
@@ -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 {
+1 -1
View File
@@ -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
+11
View File
@@ -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
-5
View File
@@ -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
+9
View File
@@ -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
+3 -6
View File
@@ -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
+2 -1
View File
@@ -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)
+40
View File
@@ -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
+2 -3
View File
@@ -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
+10 -3
View File
@@ -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
+8
View File
@@ -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)
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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
+7 -11
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
@@ -0,0 +1,6 @@
<div class="cards__list" data-drag-drop-item-container>
<div class="blank-slate blank-slate--drag card card--hide-unless-empty">
<p>Drag cards here</p>
</div>
<div class="blank-slate blank-slate--empty card--hide-unless-empty">No cards here</div>
</div>
+1 -1
View File
@@ -1,3 +1,3 @@
<div class="cards__list">
<div class="cards__list" data-drag-drop-item-container >
<%= render "cards/display/previews", cards: cards, draggable: draggable %>
</div>
@@ -17,12 +17,7 @@
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
<% end %>
<% else %>
<div class="cards__list">
<div class="blank-slate blank-slate--drag card">
<p>Drag cards here</p>
</div>
<div class="blank-slate blank-slate--empty">No cards here</div>
</div>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
<% end %>
</section>
@@ -17,12 +17,7 @@
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
<% end %>
<% else %>
<div class="cards__list">
<div class="blank-slate blank-slate--drag card">
<p>Drag cards here</p>
</div>
<div class="blank-slate blank-slate--empty">No cards here</div>
</div>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
<% end %>
</section>
+1 -6
View File
@@ -17,12 +17,7 @@
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
<% end %>
<% else %>
<div class="cards__list">
<div class="blank-slate blank-slate--drag card">
<p>Drag cards here</p>
</div>
<div class="blank-slate blank-slate--empty">No cards here</div>
</div>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
<% end %>
</section>
@@ -17,7 +17,7 @@
<%= render "boards/columns/list", cards: @page.records, draggable: true %>
<% end %>
<% else %>
<div class="blank-slate blank-slate--empty">No cards here</div>
<%= render "boards/columns/empty_placeholder" %>
<% end %>
<% end %>
</section>
+14 -5
View File
@@ -1,7 +1,16 @@
<%= form_with model: board, class: "txt-align-center margin-block-start-auto", method: :delete do |form| %>
<%= form.button class: "btn txt-negative borderless txt-small", data: { turbo_confirm: "Are you sure you want to permanently delete this board and all the cards on it? This can't be undone." } do %>
<div class="txt-align-center margin-block-start-auto" data-controller="dialog" data-dialog-modal-value="true" data-action="keydown.esc->dialog#close:stop">
<button type="button" class="btn txt-negative borderless txt-small" data-action="dialog#open">
<%= icon_tag "trash" %>
<span>Delete this board</span>
<% end %>
<% end %>
</button>
<dialog class="dialog panel fill-white shadow gap flex-column" style="max-width: 40ch" data-dialog-target="dialog">
<h3 class="txt-large txt-bold">Delete this board?</h3>
<p class="txt-medium margin-block-half">Are you sure you want to permanently delete this board and all the cards on it? This can't be undone.</p>
<div class="flex gap-half justify-center margin-block-start">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to board_path(board), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %>
<span>Delete board</span>
<% end %>
</div>
</dialog>
</div>
+6 -5
View File
@@ -1,3 +1,4 @@
<% disabled = !Current.user.can_administer_board?(board) %>
<header>
<h2 class="divider txt-medium margin-block-start">Who can access this board?</h2>
</header>
@@ -16,7 +17,7 @@
<hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-ink-medium); --border-style: dashed" aria-hidden="true" />
<label for="board_all_access" class="switch">
<%= form.check_box :all_access, class: "switch__input", checked: board.all_access?, disabled: !Current.user.can_administer_board?(board), data: { action: "change->toggle-class#toggle" } %>
<%= form.check_box :all_access, class: "switch__input", checked: board.all_access?, disabled:, data: { action: "change->toggle-class#toggle" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Give everyone access to this board</span>
</label>
@@ -26,14 +27,14 @@
<div class="toggler__visible-when-off">
<div class="flex align-center justify-end gap-half">
<button type="button" class="btn btn--plain txt-x-small txt-link font-weight-normal" data-action="click->toggle-class#checkAll">Select all</button>
<%= button_tag "Select all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkAll" }, disabled: %>
<span class="txt-subtle">&middot;</span>
<button type="button" class="btn btn--plain txt-x-small txt-link font-weight-normal" data-action="click->toggle-class#checkNone">Select none</button>
<%= button_tag "Select none", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkNone" }, disabled: %>
</div>
</div>
<ul class="settings__user-list" data-filter-target="list">
<%= access_toggles_for selected_users, selected: true, disabled: !Current.user.can_administer_board?(board) %>
<%= access_toggles_for unselected_users, selected: false, disabled: !Current.user.can_administer_board?(board) %>
<%= access_toggles_for selected_users, selected: true, disabled: %>
<%= access_toggles_for unselected_users, selected: false, disabled: %>
</ul>
<% end %>
+2 -2
View File
@@ -1,9 +1,9 @@
<% @page_title = "Create a new board" %>
<div class="panel panel--centered">
<%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form" } do |form| %>
<%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>
<h1 class="txt-x-large margin-none font-weight-black"><%= @page_title %></h1>
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off",placeholder: "Name it…", data: { action: "keydown.esc@document->form#cancel" } %>
<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names cant be blank" } %>
<button type="submit" class="btn btn--link center">
<span>Create board</span>
+2 -1
View File
@@ -1,4 +1,5 @@
<%= 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};",
<%= column_tag id: dom_id(column), name: column.name, drop_url: columns_card_drops_column_path("__id__", column_id: column.id), card_color: column.color.to_s,
class: "cards--doing", style: "--card-color: #{column.color};",
data: {
drag_and_strum_target: "container",
collapsible_columns_target: "column",
+1 -1
View File
@@ -1,4 +1,4 @@
<button class="cards__expander btn btn--plain" data-collapsible-columns-target="button" data-action="click->collapsible-columns#toggle click->navigable-list#selectCurrentOrReset"
<button class="cards__expander btn btn--plain" data-collapsible-columns-target="button" data-css-variable-counter-target="counter" 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">
+3
View File
@@ -13,5 +13,8 @@
<%= with_automatic_pagination "the-stream", @page do %>
<%= render "boards/columns/list", cards: page.records, draggable: true %>
<% end %>
<% else %>
<div class="cards__list" data-drag-drop-item-container>
</div>
<% end %>
<% end %>
@@ -1,4 +0,0 @@
<%= turbo_stream.remove dom_id(card, "bubble") %>
<%= turbo_stream.replace dom_id(card, :card_closure_toggle) do %>
<%= render "cards/container/closure", card: card %>
<% end %>
+16
View File
@@ -0,0 +1,16 @@
<div data-controller="dialog" data-dialog-modal-value="true" data-action="keydown.esc->dialog#close:stop">
<button type="button" class="btn txt-negative borderless txt-small" data-action="dialog#open">
<%= icon_tag "trash" %>
<span>Delete this card</span>
</button>
<dialog class="dialog panel fill-white shadow gap flex-column" style="max-width: 40ch" data-dialog-target="dialog">
<h3 class="txt-large txt-bold">Delete this card?</h3>
<p class="txt-medium margin-block-half">Are you sure you want to permanently delete this card?</p>
<div class="flex gap-half justify-center margin-block-start">
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
<%= button_to card_path(card), method: :delete, class: "btn txt-negative", data: { turbo_frame: "_top" } do %>
<span>Delete card</span>
<% end %>
</div>
</dialog>
</div>
+1 -1
View File
@@ -9,7 +9,7 @@
<% if Current.user.can_administer_card?(card) %>
<footer class="delete-card txt-align-center full-width flex flex-column margin-block-start-double">
<hr class="separator--horizontal full-width margin-block" style="--border-color: var(--color-ink-lighter)">
<%= button_to_delete_card(card) %>
<%= render "cards/delete", card: card %>
</footer>
<% end %>
<% end %>
@@ -2,14 +2,14 @@
<%= form_with model: [ @comment.card, @comment, Reaction.new ],
class: "reaction reaction__form expanded",
html: { aria: { label: "New reaction" } },
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit" } do |form| %>
data: { controller: "form reaction-emoji", turbo_frame: dom_id(@comment, :reacting), action: "keydown.esc->form#cancel submit->form#preventEmptySubmit submit->form#preventComposingSubmit" } do |form| %>
<label class="reaction__form-label flex gap" style="--column-gap: 0.4ch;">
<figure class="reaction__avatar margin-none flex-item-no-shrink">
<%= avatar_tag Current.user %>
</figure>
<%= form.text_field :content, autofocus: true, autocomplete: "off", autocorrect: "off", maxlength: 16,
pattern: /\S+.*/, class: "input reaction__input txt-small", data: { form_target: "input", reaction_emoji_target: "input" }, aria: { label: "Add a reaction" } %>
pattern: /\S+.*/, class: "input reaction__input txt-small", data: { form_target: "input", reaction_emoji_target: "input", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a reaction" } %>
</label>
<%= render "cards/comments/reactions/menu", comment: @comment %>
+1 -1
View File
@@ -1,6 +1,6 @@
<% 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", navigable_list_target: "item" }, tabindex: 0 do %>
<%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.number, drag_and_drop_target: "item", navigable_list_target: "item", css_variable_counter_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>
@@ -4,8 +4,8 @@
<% unless card.closed? %>
<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), 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" } %>
<%= form_with model: [card, Step.new], url: card_steps_path(card), class: "min-width", data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit 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", action: "compositionstart->form#compositionStart compositionend->form#compositionEnd" }, aria: { label: "Add a step" } %>
<% end %>
</li>
<% end %>
@@ -4,7 +4,7 @@
class: "bubble",
data: {
controller: "bubble",
action: "turbo:morph-element->bubble#update",
action: "turbo:morph-element->bubble#update:self",
bubble_entropy_value: entropy_bubble_options_for(card).to_json,
bubble_stalled_value: stalled_bubble_options_for(card)&.to_json
} do %>
+2 -2
View File
@@ -11,10 +11,10 @@
<%= form_with url: card_taggings_path(@card),
id: dom_id(@card, :tags_form),
data: { controller: "form", action: "submit->form#preventEmptySubmit" },
data: { controller: "form", action: "submit->form#preventEmptySubmit submit->form#preventComposingSubmit" },
class: "flex flex-column gap-half full-width margin-block-half" do |form| %>
<%= form.text_field :tag_title, placeholder: @tags.any? ? "Add a new tag or filter…" : "Name this tag…", class: "input txt-small full-width",
autocomplete: "off", autofocus: true, data: { filter_target: "input", form_target: "input", action: "input->filter#filter" } %>
autocomplete: "off", autofocus: true, data: { filter_target: "input", form_target: "input", action: "input->filter#filter compositionstart->form#compositionStart compositionend->form#compositionEnd" } %>
<% end %>
<ul class="popup__list" data-filter-target="list">
@@ -3,7 +3,7 @@
<%= column.title %>
<% if column.events_by_hour.any? %>
<%= link_to events_day_timeline_column_path(id: column.base_title_for_route, day: column.day_timeline.day.to_date), class: "events__maximize-button btn btn--circle txt-x-small borderless", data: { turbo_frame: "_top" } do %>
<%= link_to events_day_timeline_column_path(column, day: column.day_timeline.day.to_date), class: "events__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 %>
@@ -2,7 +2,7 @@
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Actvity", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
<%= back_link_to "Activity", root_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title">
+1 -1
View File
@@ -1,5 +1,5 @@
<%= tag.dialog class:"lightbox", aria: { label: "Image Viewer (Press escape to close)" },
data: { controller: "dialog", dialog_target: "dialog", dialog_modal_value: "true", lightbox_target: "dialog", action: "keydown.esc->dialog#close:stop" } do %>
data: { controller: "dialog", dialog_target: "dialog", dialog_modal_value: "true", lightbox_target: "dialog", action: "keydown.esc->dialog#close:stop transitionend->lightbox#handleTransitionEnd" } do %>
<figure class="lightbox__figure">
<img src="" class="lightbox__image" data-lightbox-target="zoomedImage" />
@@ -1,5 +1,10 @@
<%= turbo_frame_tag :cards_container do %>
<div class="card-columns hide-scrollbar" data-controller="collapsible-columns" data-collapsible-columns-board-value="<%= board.id %>" data-collapsible-columns-collapsed-class="is-collapsed" data-collapsible-columns-no-transitions-class="no-transitions">
<div class="card-columns hide-scrollbar"
data-controller="collapsible-columns"
data-collapsible-columns-board-value="<%= board.id %>"
data-collapsible-columns-collapsed-class="is-collapsed"
data-collapsible-columns-no-transitions-class="no-transitions"
data-collapsible-columns-title-not-visible-class="is-off-screen">
<div class="card-columns__left">
<%= render "public/boards/show/not_now", board: board %>
+1 -1
View File
@@ -15,7 +15,7 @@
<% end %>
</div>
<div class="overflow-ellipsis translucent txt-ink">
<%= result.card.board.name %>
<%= result.card.board.name %> · <%= local_datetime_tag(result.created_at, style: :timeordate) %>
</div>
<% end %>
</li>
+7 -5
View File
@@ -10,16 +10,18 @@
<%= form.text_field :code, required: true, class: "input center txt-align-enter txt-large",
autofocus: true, autocorrect: "off", autocapitalize: "off", spellcheck: "false", "data-1p-ignore": true,
autocomplete: "one-time-code", maxlength: "6", placeholder: "••••••", value: params[:code],
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submit paste->magic-link#submit" } %>
data: { magic_link_target: "input", action: "keydown.enter->magic-link#submitOnEnter paste->magic-link#submitOnPaste" } %>
<% end %>
<p class="txt-small">The code you receive will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
</div>
<% if Rails.env.development? %>
<script>
console.log("Magic link code: <%= flash[:magic_link_code].presence || "not generated" %>");
</script>
<% if Rails.env.development? && flash[:magic_link_code].present? %>
<div class="flex align-center justify-center gap fill-shade border-radius pad margin-block-start full-width position-relative" style="animation: slide-up-fade-in 400ms ease-out both; max-width: 42ch; overflow: hidden;">
<span class="txt-uppercase translucent txt-tight-lines">Psst, here's your code:</span>
<code class="txt-large font-weight-black txt-tight-lines"><%= flash[:magic_link_code] %></code>
<span class="txt-xx-small txt-uppercase font-weight-black txt-align-center flex align-center justify-center" style="position: absolute; top: 0; bottom: 0; left: 0; background: var(--color-ink-lighter); padding: 0 0.5em; writing-mode: vertical-rl; transform: rotate(180deg); line-height: 1;">DEV</span>
</div>
<% end %>
<% content_for :footer do %>
+3 -2
View File
@@ -9,8 +9,9 @@
<p>You're one step away. Just enter your name to get your own Fizzy account.</p>
<% if @signup.errors.any? %>
<div class="margin-block-half">
<ul class="margin-block-none txt-negative txt-small">
<div class="margin-block-half txt-negative txt-small" style="text-align: left;">
<p class="margin-block-none font-weight-bold">Your changes couldn't be saved:</p>
<ul class="margin-block-none">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
+13 -1
View File
@@ -14,7 +14,7 @@
<label class="avatar btn btn--circle input--file txt-xx-large center fill-white">
<%= image_tag user_avatar_path(@user), aria: { hidden: "true" }, class: "avatar", size: 128, data: { upload_preview_target: "image" } %>
<%= form.file_field :avatar, id: "file", class: "input", accept: "image/jpeg, image/png, image/gif, image/webp",
<%= form.file_field :avatar, id: "file", class: "input", accept: User::Avatar::ALLOWED_AVATAR_CONTENT_TYPES.join(","),
data: { upload_preview_target: "input", action: "upload-preview#previewImage" } %>
<span class="for-screen-reader">Profile avatar for <%= @user.name %></span>
</label>
@@ -38,6 +38,18 @@
<%= link_to "Change email", new_user_email_address_path(user_id: Current.user.id), class: "btn btn--plain txt-link txt-small txt-nowrap" %>
</div>
</div>
<% if @user.errors.any? %>
<div class="margin-block-half txt-negative txt-small" style="text-align: left;">
<p class="margin-block-none font-weight-bold">Your changes couldn't be saved:</p>
<ul class="margin-block-none">
<% @user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" id="log_in" class="btn btn--reversed center" data-form-target="submit">
<span>Save changes</span>
</button>
+17 -10
View File
@@ -18,20 +18,25 @@
<div class="flex flex-column gap-half margin-block-end">
<h1 class="txt-x-large margin-none"><%= @user.name %></h1>
<div class="txt-medium">
<% if @user.active? %>
<%= mail_to @user.identity.email_address %>
<% else %>
<% if !@user.active? %>
<%= @user.name %> is no longer on this account
<% elsif !@user.verified? %>
Unverified
<div class="txt-small txt-tinted">A sign-in code has been sent to this email address, but the user has not yet logged in to confirm their identity.</div>
<% else %>
<%= mail_to @user.identity.email_address %>
<% end %>
</div>
</div>
<div class="flex-inline center justify-center flex-wrap gap">
<%= link_to "Which cards are assigned to #{me_or_you}?",
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
<%= link_to "Which cards were added by #{me_or_you}?",
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
</div>
<% if @user.verified? %>
<div class="flex-inline center justify-center flex-wrap gap">
<%= link_to "Which cards are assigned to #{me_or_you}?",
cards_path(assignee_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
<%= link_to "Which cards were added by #{me_or_you}?",
cards_path(creator_ids: [ @user.id ], sorted_by: "newest"), class: "btn", data: { turbo_frame: "_top" } %>
</div>
<% end %>
</div>
</section>
@@ -49,4 +54,6 @@
<% end %>
</div>
<%= turbo_frame_tag "user_events", src: user_events_path(@user) %>
<% if @user.verified? %>
<%= turbo_frame_tag "user_events", src: user_events_path(@user) %>
<% end %>
@@ -0,0 +1 @@
<%= auto_submit_form_with url: users_verifications_path, method: :post %>
+1 -1
View File
@@ -22,7 +22,7 @@
</header>
<% unless @webhook.active? %>
<div>
<%= button_to "Reactivate", board_webhook_activation_path(@webhook), method: :post %>
<%= button_to "Reactivate", board_webhook_activation_path(@webhook.board_id, @webhook), method: :post %>
</div>
<% end %>
+27 -86
View File
@@ -1,65 +1,45 @@
# Name of your application. Used to uniquely configure containers.
# Name of this app
service: fizzy
# Name of the container image (use your-user/app-name on external registries).
image: fizzy
# Deploy to these servers.
#-- About your deployment --#
# Where to deploy fizzy
servers:
web:
- 192.168.0.1
# job:
# hosts:
# - 192.168.0.1
# cmd: bin/jobs
- fizzy.example.com # Set your server name here
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
# If used with Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption.
#
# Using an SSL proxy like this requires turning on config.assume_ssl and config.force_ssl in production.rb!
#
# Don't use this when deploying to multiple web servers (then you have to terminate SSL at your load balancer).
#
# proxy:
# ssl: true
# host: app.example.com
# How you connect to your server
ssh:
user: root # If you use a different username to SSH to your server, specify it here
# Where you keep your container images.
registry:
# Alternatives: hub.docker.com / registry.digitalocean.com / ghcr.io / ...
server: localhost:5555
# Automatic SSL
proxy:
ssl: true # Set this to false if you *don't* want SSL
host: fizzy.example.com # Set your server name here to use automatic SSL
# Needed for authenticated registries.
# username: your-user
# Always use an access token rather than real password when possible.
# password:
# - KAMAL_REGISTRY_PASSWORD
# Inject ENV variables into containers (secrets come from .kamal/secrets).
# Your application configuration (secrets come from .kamal/secrets).
env:
secret:
- RAILS_MASTER_KEY
- SECRET_KEY_BASE
- VAPID_PUBLIC_KEY
- VAPID_PRIVATE_KEY
- SMTP_USERNAME
- SMTP_PASSWORD
clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
MAILER_FROM_ADDRESS: support@example.com # The email address that Fizzy sends email from
SOLID_QUEUE_IN_PUMA: true # Run background jobs in the app container
# Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3
# Set number of cores available to the application on each server (default: 1).
# WEB_CONCURRENCY: 2
#-- General configuration --#
# Match this to any external database server to configure Active Record correctly
# Use fizzy-db for a db accessory server on same machine via local kamal docker network.
# DB_HOST: 192.168.0.2
# Use a local registry to deploy
registry:
server: localhost:5555
# Log everything from Rails
# RAILS_LOG_LEVEL: debug
# Aliases are triggered with "bin/kamal <alias>". You can overwrite arguments on invocation:
# "bin/kamal logs -r job" will tail logs from the first server in the job section.
# Handy aliases for interacting with your deployment. For eaxmple: `bin/kamal console` will connect to a
# Rails console in production.
aliases:
console: app exec --interactive --reuse "bin/rails console"
shell: app exec --interactive --reuse "bash"
@@ -76,45 +56,6 @@ volumes:
# version inside the asset_path.
asset_path: /rails/public/assets
# Configure the image builder.
builder:
arch: amd64
# # Build image via remote server (useful for faster amd64 builds on arm64 computers)
# remote: ssh://docker@docker-builder-server
#
# # Pass arguments and secrets to the Docker build process
# args:
# RUBY_VERSION: ruby-3.4.7
# secrets:
# - GITHUB_TOKEN
# - RAILS_MASTER_KEY
# Use a different ssh user than root
# ssh:
# user: app
# Use accessory services (secrets come from .kamal/secrets).
# accessories:
# db:
# image: mysql:8.0
# host: 192.168.0.2
# # Change to 3306 to expose port to the world instead of just local network.
# port: "127.0.0.1:3306:3306"
# env:
# clear:
# MYSQL_ROOT_HOST: '%'
# secret:
# - MYSQL_ROOT_PASSWORD
# files:
# - config/mysql/production.cnf:/etc/mysql/my.cnf
# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql
# directories:
# - data:/var/lib/mysql
# redis:
# image: valkey/valkey:8
# host: 192.168.0.2
# port: 6379
# directories:
# - data:/data
+15
View File
@@ -3,6 +3,21 @@ require "active_support/core_ext/integer/time"
Rails.application.configure do
# Settings specified here will take precedence over those in config/application.rb.
# Email provider Settings
#
# Configure these according to whichever email provider you use. An example setup
# using SMTP looks like the following:
#
# config.action_mailer.smtp_settings = {
# address: 'smtp.example.com', # The address of your email provider's SMTP server
# port: 2525,
# domain: 'example.com', # Your domain, which Fizzy will send email from
# user_name: ENV["SMTP_USERNAME"],
# password: ENV["SMTP_PASSWORD"],
# authentication: :plain,
# enable_starttls_auto: true
# }
# Code is not reloaded between requests.
config.enable_reloading = false
+1 -1
View File
@@ -7,7 +7,7 @@ module ActionText
# This overrides the default :embeds association!
has_many_attached :embeds do |attachable|
::Attachments::VARIANTS.each do |variant_name, variant_options|
attachable.variant variant_name, variant_options.merge(preprocessed: true)
attachable.variant variant_name, **variant_options, process: :immediately
end
end
end
+11 -3
View File
@@ -4,9 +4,17 @@ ActiveSupport.on_load(:active_storage_blob) do
end
end
ActiveSupport.on_load(:active_storage_record) do
configure_replica_connections
end
# Don't configure replica connections for ActiveStorage::Record.
# When ActiveStorage uses `connects_to`, it creates a separate connection pool
# from ApplicationRecord. This causes after_commit callbacks to fire in
# non-deterministic order - the Attachment's create_variants callback can fire
# before the User model's upload callback, causing FileNotFoundError when
# using `process: :immediately` for variants.
# See: https://github.com/rails/rails/issues/53694
#
# ActiveSupport.on_load(:active_storage_record) do
# configure_replica_connections
# end
module ActiveStorageControllerExtensions
extend ActiveSupport::Concern
+8 -6
View File
@@ -3,12 +3,14 @@
Autotuner.enabled = true
# This callback is called whenever a suggestion is provided by this gem.
# You can output this report to your logging pipeline, stdout, a file,
# or somewhere else!
Autotuner.reporter = proc do |report|
# Log to structured logging for query/analysis in Loki. This is called
# once per autotuner heuristic.
Autotuner.reporter = proc do |heuristic_report|
report = heuristic_report.to_s
Rails.logger.info "GCAUTOTUNE: #{report}"
if Fizzy.saas?
Sentry.capture_message "Autotuner suggestion", level: :info, extra: { report: report.to_s }
end
RailsStructuredLogging.instrument_script "autotuner" do
Rails.logger.info report.to_s
end if defined? RailsStructuredLogging
end
+42 -22
View File
@@ -1,25 +1,45 @@
# Be sure to restart your server when you modify this file.
# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header
# Define an application-wide Content Security Policy.
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
# Rails.application.configure do
# config.content_security_policy do |policy|
# policy.default_src :self, :https
# policy.font_src :self, :https, :data
# policy.img_src :self, :https, :data
# policy.object_src :none
# policy.script_src :self, :https
# policy.style_src :self, :https
# # Specify URI for violation reports
# # policy.report_uri "/csp-violation-report-endpoint"
# end
#
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
# config.content_security_policy_nonce_directives = %w(script-src style-src)
#
# # Report violations without enforcing the policy.
# # config.content_security_policy_report_only = true
# end
Rails.application.configure do
# Configure with environment variables with fallback to config.x values (via fizzy-sass)
report_uri = ENV.fetch("CSP_REPORT_URI") { config.x.content_security_policy.report_uri }
report_only =
if ENV.key?("CSP_REPORT_ONLY")
ENV["CSP_REPORT_ONLY"] == "true"
else
config.x.content_security_policy.report_only
end
# Generate nonces for importmap and inline scripts
config.content_security_policy_nonce_generator = ->(request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[ script-src ]
config.content_security_policy do |policy|
policy.default_src :self
policy.script_src :self, "https://challenges.cloudflare.com"
policy.connect_src :self, "https://storage.basecamp.com"
policy.frame_src :self, "https://challenges.cloudflare.com"
# Don't fight user tools: permit inline styles, data:/https: sources, and
# blob: workers for accessibility extensions, privacy tools, and custom fonts.
policy.style_src :self, :unsafe_inline
policy.img_src :self, "blob:", "data:", "https:"
policy.font_src :self, "data:", "https:"
policy.media_src :self, "blob:", "data:", "https:"
policy.worker_src :self, "blob:"
policy.object_src :none
policy.base_uri :none
policy.form_action :self
policy.frame_ancestors :self
# Specify URI for violation reports (e.g., Sentry CSP endpoint)
policy.report_uri report_uri if report_uri
end
# Report violations without enforcing the policy.
config.content_security_policy_report_only = report_only
end unless ENV["DISABLE_CSP"]
+7
View File
@@ -0,0 +1,7 @@
# Lazily add identity and account context to error reports.
# Only evaluated when an error is actually reported.
Rails.error.add_middleware ->(error, context:, **) do
context.merge \
identity_id: Current.identity&.id,
account_id: Current.account&.external_account_id
end
+9 -1
View File
@@ -17,7 +17,15 @@ end
module WebPush::PersistentRequest
def perform
if @options[:connection]
endpoint_ip = @options[:endpoint_ip]
if endpoint_ip
http = Net::HTTP.new(uri.host, uri.port, ipaddr: endpoint_ip)
http.use_ssl = true
http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil?
http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil?
http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil?
elsif @options[:connection]
http = @options[:connection]
else
http = Net::HTTP.new(uri.host, uri.port, *proxy_options)
+1 -1
View File
@@ -25,7 +25,7 @@ if !Rails.env.local?
# worker per CPU, 1 thread per worker and tune it from there.
#
# https://edgeguides.rubyonrails.org/tuning_performance_for_deployment.html#puma
workers Concurrent.physical_processor_count
workers Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count })
threads 1, 1
# Tell the Ruby VM that we're finished booting up.
+1 -1
View File
@@ -5,7 +5,7 @@ default: &default
workers:
- queues: [ "default", "solid_queue_recurring", "backend", "webhooks" ]
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", Concurrent.physical_processor_count) %>
processes: <%= Integer(ENV.fetch("JOB_CONCURRENCY") { Concurrent.physical_processor_count }) %>
polling_interval: 0.1
development: *default
+1
View File
@@ -138,6 +138,7 @@ Rails.application.routes.draw do
namespace :users do
resources :joins
resources :verifications, only: %i[ new create ]
end
resource :session do
@@ -0,0 +1,5 @@
class AddVerifiedAtToUsers < ActiveRecord::Migration[8.2]
def change
add_column :users, :verified_at, :datetime
end
end
Generated
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
ActiveRecord::Schema[8.2].define(version: 2025_12_05_010536) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at"
t.uuid "account_id", null: false
@@ -726,6 +726,7 @@ ActiveRecord::Schema[8.2].define(version: 2025_12_01_100607) do
t.string "name", null: false
t.string "role", default: "member", null: false
t.datetime "updated_at", null: false
t.datetime "verified_at"
t.index ["account_id", "identity_id"], name: "index_users_on_account_id_and_identity_id", unique: true
t.index ["account_id", "role"], name: "index_users_on_account_id_and_role"
t.index ["identity_id"], name: "index_users_on_identity_id"

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