diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..f4f8f439c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Features, Bug Reports, Questions + url: https://github.com/basecamp/fizzy/discussions/new/choose + about: Please use the discussions area to report issues or ask quest diff --git a/.github/ISSUE_TEMPLATE/preapproved.md b/.github/ISSUE_TEMPLATE/preapproved.md new file mode 100644 index 000000000..153e13a3b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/preapproved.md @@ -0,0 +1,8 @@ +--- +name: Pre-Discussed and Approved Topics +about: |- + For topics already discussed and approved in the GitHub Discussions section. +--- + +** PLEASE START A DISCUSSION INSTEAD OF OPENING AN ISSUE ** +** For more details see CONTRIBUTING.md ** diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 000000000..4970ef182 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,196 @@ +name: Build and publish container image to GHCR + +on: + push: + branches: + - main + tags: + - 'v*' + workflow_dispatch: + +concurrency: + group: publish-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + id-token: write + attestations: write + +env: + IMAGE_DESCRIPTION: Fizzy is Kanban as it should be. Not as it has been. + SOURCE_URL: https://github.com/${{ github.repository }} + +jobs: + build: + name: Build and push image (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + platform: linux/amd64 + arch: amd64 + env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + steps: + - name: Checkout + uses: actions/checkout@v5.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.11.1 + + - name: Log in to GHCR + uses: docker/login-action@v3.5.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute canonical image name (lowercase) + id: vars + shell: bash + run: | + set -eu + IMAGE_REF="${IMAGE_NAME:-$GITHUB_REPOSITORY}" + CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}" + echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Extract Docker metadata (tags, labels) with arch suffix + id: meta + uses: docker/metadata-action@v5.8.0 + with: + images: ${{ steps.vars.outputs.canonical }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,format=short,prefix=sha- + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + flavor: | + latest=false + suffix=-${{ matrix.arch }} + labels: | + org.opencontainers.image.source=${{ env.SOURCE_URL }} + + - name: Build and push (${{ matrix.platform }}) + id: build + uses: docker/build-push-action@v6.18.0 + with: + context: . + file: Dockerfile + build-args: | + OCI_SOURCE=${{ env.SOURCE_URL }} + OCI_DESCRIPTION=${{ env.IMAGE_DESCRIPTION }} + platforms: ${{ matrix.platform }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,scope=${{ matrix.platform }},mode=max + sbom: false + provenance: false + + - name: Attest image provenance (per-arch) + if: github.event_name != 'pull_request' + uses: actions/attest-build-provenance@v3.0.0 + with: + subject-name: ${{ steps.vars.outputs.canonical }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: false + + manifest: + name: Create multi-arch manifest and sign + needs: build + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + steps: + - name: Set up Docker Buildx (for imagetools) + uses: docker/setup-buildx-action@v3.11.1 + + - name: Log in to GHCR + uses: docker/login-action@v3.5.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Compute canonical image name (lowercase) + id: vars + shell: bash + run: | + set -eu + IMAGE_REF="${IMAGE_NAME:-$GITHUB_REPOSITORY}" + CANONICAL_IMAGE="${REGISTRY}/${IMAGE_REF,,}" + echo "canonical=${CANONICAL_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Compute base tags (no suffix) + id: meta + uses: docker/metadata-action@v5.8.0 + with: + images: ${{ steps.vars.outputs.canonical }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha,format=short,prefix=sha- + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + flavor: | + latest=false + labels: | + org.opencontainers.image.source=${{ env.SOURCE_URL }} + + - name: Create multi-arch manifests + shell: bash + run: | + set -eu + tags="${{ steps.meta.outputs.tags }}" + echo "Creating manifests for tags:" + printf '%s\n' "$tags" + while IFS= read -r tag; do + [ -z "$tag" ] && continue + echo "Creating manifest for $tag" + src_tag="$tag" + if [[ "$tag" == *:latest && "${GITHUB_REF}" == refs/tags/* ]]; then + ref="${GITHUB_REF#refs/tags/}" + src_tag="${tag%:latest}:$ref" + fi + if [ -n "${IMAGE_DESCRIPTION:-}" ]; then + docker buildx imagetools create \ + --tag "$tag" \ + --annotation "index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}" \ + "${src_tag}-amd64" + else + docker buildx imagetools create \ + --tag "$tag" \ + "${src_tag}-amd64" + fi + done <<< "$tags" + + - name: Install Cosign + uses: sigstore/cosign-installer@v3.9.2 + + - name: Cosign sign all tags (keyless OIDC) + shell: bash + run: | + set -eu + tags="${{ steps.meta.outputs.tags }}" + printf '%s\n' "$tags" + while IFS= read -r tag; do + [ -z "$tag" ] && continue + echo "Signing $tag" + cosign sign --yes "$tag" + done <<< "$tags" + + diff --git a/AGENTS.md b/AGENTS.md index 203422200..ee391cc03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ Key recurring tasks (via `config/recurring.yml`): URL: `http://fizzy.localhost:3006` Login: david@37signals.com (passwordless magic link auth - check rails console for link) -Use Chrome MCP tools to interact with the running dev app for UI testing and debugging.`` +Use Chrome MCP tools to interact with the running dev app for UI testing and debugging. ## Coding style diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..f981eb646 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# How to contribute to Fizzy + +Fizzy uses GitHub +[discussions](https://github.com/basecamp/fizzy/discussions) to track +feature requests and questions, rather than [the issue +tracker](https://github.com/basecamp/fizzy/issues). If you're considering +opening an issue or pull request, please open a discussion instead. + +Whenever a discussion leads to an actionable and well-understood task, we'll +move it to the issue tracker where it can be worked on. + +This is a little different than how some other projects work, but it makes it +easier for us to triage and prioritise the work. It also means that the open +issues all represent agreed-upon tasks that are either being worked on, or are +ready to be worked on. + +This should also make it easier to see what's in progress, and to find +something to work on if you'd like to do so. + +## What this means in practice + +### If you'd like to contribute to the code... + +1. If you're interested in working on one of the open issues, please do! We are + grateful for the help! +2. You'll want to make sure someone else isn't already working on the same + issue. If they are, it will be tagged "in progress" and/or it should be clear + from the comments. When in doubt, you can always comment on the issue to ask. +3. Similarly, if you need any help or guidance on the issue, please comment on + the issue as you go, and we'll do our best to help. +4. When you have something ready for review or collaboration, open a PR. + +### If you've found a bug... + +1. If you don't have steps to reproduce the problem, or you're not certain it's a + bug, open a discussion. +2. If you have steps to reproduce, open an issue. + +### If you have an idea for a feature... + +1. Open a discussion. + +### If you have a question, or are having trouble with configuration... + +1. Open a discussion. + +Hopefully this process makes it easier for everyone to be involved. Thanks for +helping! ❤️ + diff --git a/Dockerfile b/Dockerfile index 3cd25c7f1..59cb8cfef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ WORKDIR /rails # Install base packages RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 libssl-dev && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives @@ -59,6 +59,13 @@ RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile # Final stage for app image FROM base +# Image metadata +ARG OCI_DESCRIPTION +LABEL org.opencontainers.image.description="${OCI_DESCRIPTION}" +ARG OCI_SOURCE +LABEL org.opencontainers.image.source="${OCI_SOURCE}" +LABEL org.opencontainers.image.licenses="O'Saasy" + # Run and own only the runtime files as a non-root user for security RUN groupadd --system --gid 1000 rails && \ useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash diff --git a/Gemfile b/Gemfile index 63c98f89c..7f7a463bb 100644 --- a/Gemfile +++ b/Gemfile @@ -33,6 +33,7 @@ gem "platform_agent" gem "aws-sdk-s3", require: false gem "web-push" gem "net-http-persistent" +gem "rubyzip", require: "zip" gem "mittens" gem "useragent", bc: "useragent" diff --git a/Gemfile.lock b/Gemfile.lock index 502c14965..e24a38a82 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -144,8 +144,8 @@ GEM brakeman (7.1.1) racc builder (3.3.0) - bundler-audit (0.9.2) - bundler (>= 1.2.0, < 3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) capybara (3.40.0) addressable @@ -179,7 +179,13 @@ GEM faker (3.5.2) i18n (>= 1.8.11, < 2) ffi (1.17.2) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86-linux-gnu) + ffi (1.17.2-x86-linux-musl) ffi (1.17.2-x86_64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) @@ -285,6 +291,14 @@ GEM nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-musl) + racc (~> 1.4) nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) nokogiri (1.18.10-x86_64-darwin) @@ -347,7 +361,7 @@ GEM io-console (~> 0.5) rexml (3.4.4) rouge (4.6.1) - rqrcode (3.1.0) + rqrcode (3.1.1) chunky_png (~> 1.0) rqrcode_core (~> 2.0) rqrcode_core (2.0.1) @@ -408,12 +422,18 @@ GEM fugit (~> 1.11) railties (>= 7.1) thor (>= 1.3.1) - sqlite3 (2.8.0) + sqlite3 (2.8.1) mini_portile2 (~> 2.8.0) - sqlite3 (2.8.0-arm64-darwin) - sqlite3 (2.8.0-x86_64-darwin) - sqlite3 (2.8.0-x86_64-linux-gnu) - sqlite3 (2.8.0-x86_64-linux-musl) + sqlite3 (2.8.1-aarch64-linux-gnu) + sqlite3 (2.8.1-aarch64-linux-musl) + sqlite3 (2.8.1-arm-linux-gnu) + sqlite3 (2.8.1-arm-linux-musl) + sqlite3 (2.8.1-arm64-darwin) + sqlite3 (2.8.1-x86-linux-gnu) + sqlite3 (2.8.1-x86-linux-musl) + sqlite3 (2.8.1-x86_64-darwin) + sqlite3 (2.8.1-x86_64-linux-gnu) + sqlite3 (2.8.1-x86_64-linux-musl) sshkit (1.24.0) base64 logger @@ -426,6 +446,7 @@ GEM stringio (3.1.8) thor (1.4.0) thruster (0.1.16) + thruster (0.1.16-aarch64-linux) thruster (0.1.16-arm64-darwin) thruster (0.1.16-x86_64-darwin) thruster (0.1.16-x86_64-linux) @@ -465,8 +486,16 @@ GEM zeitwerk (2.7.3) PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl arm64-darwin arm64-linux + ruby + x86-linux-gnu + x86-linux-musl x86_64-darwin x86_64-linux x86_64-linux-gnu @@ -503,6 +532,7 @@ DEPENDENCIES rouge rqrcode rubocop-rails-omakase + rubyzip selenium-webdriver solid_cable (>= 3.0) solid_cache (~> 1.0) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f1c1b3b98..4e243474c 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/basecamp/fizzy-saas - revision: 130c8b23f9861a89feb59ca68f192bd05d51310c + revision: 6dd8bb616632edfabc3d814f88f0658d96486e17 specs: fizzy-saas (0.1.0) prometheus-client-mmap @@ -239,6 +239,7 @@ GEM ffi (1.17.2-aarch64-linux-musl) ffi (1.17.2-arm-linux-gnu) ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) ffi (1.17.2-x86_64-linux-musl) fugit (1.12.1) @@ -347,6 +348,8 @@ GEM racc (~> 1.4) nokogiri (1.18.10-arm-linux-musl) racc (~> 1.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) @@ -379,6 +382,11 @@ GEM bigdecimal logger rb_sys (~> 0.9.117) + prometheus-client-mmap (1.3.0-arm64-darwin) + base64 + bigdecimal + logger + rb_sys (~> 0.9.117) prometheus-client-mmap (1.3.0-x86_64-linux-gnu) base64 bigdecimal @@ -508,6 +516,7 @@ GEM sqlite3 (2.8.0-aarch64-linux-musl) sqlite3 (2.8.0-arm-linux-gnu) sqlite3 (2.8.0-arm-linux-musl) + sqlite3 (2.8.0-arm64-darwin) sqlite3 (2.8.0-x86_64-linux-gnu) sqlite3 (2.8.0-x86_64-linux-musl) sshkit (1.24.0) @@ -523,6 +532,7 @@ GEM thor (1.4.0) thruster (0.1.16) thruster (0.1.16-aarch64-linux) + thruster (0.1.16-arm64-darwin) thruster (0.1.16-x86_64-linux) timeout (0.4.4) trilogy (2.9.0) @@ -593,6 +603,8 @@ PLATFORMS aarch64-linux-musl arm-linux-gnu arm-linux-musl + arm64-darwin + x86_64-linux x86_64-linux-gnu x86_64-linux-musl @@ -632,6 +644,7 @@ DEPENDENCIES rouge rqrcode rubocop-rails-omakase + rubyzip selenium-webdriver sentry-rails sentry-ruby diff --git a/README.md b/README.md index e88bca3c3..b668747e9 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,15 @@ The full continuous integration tests can be run with: ### Database configuration -Fizzy supports SQLite (default, recommended for most scenarios) and MySQL. You can switch adapters with the `DATABASE_ADAPTER` environment variable. +Fizzy works with SQLite by default and supports MySQL too. You can switch adapters with the `DATABASE_ADAPTER` environment variable. For example, to develop locally against MySQL: ```sh -DATABASE_ADAPTER=mysql bin/rails -DATABASE_ADAPTER=mysql bin/test -bin/ci # Runs tests against both SQLite and MySQL +DATABASE_ADAPTER=mysql bin/setup --reset +DATABASE_ADAPTER=mysql bin/ci ``` +The remote CI pipeline will run tests against both SQLite and MySQL. + ### Outbound Emails You can view email previews at http://fizzy.localhost:3006/rails/mailers. diff --git a/STYLE.md b/STYLE.md index b31f49cab..941e855f9 100644 --- a/STYLE.md +++ b/STYLE.md @@ -126,7 +126,7 @@ end If a module only has private methods, we mark it `private` at the top and add an extra new line after but don't indent. ```ruby -class SomeModule +module SomeModule private def some_private_method diff --git a/app/assets/stylesheets/web/autoresize.css b/app/assets/stylesheets/web/autoresize.css index ac16e04a4..b3d2323ed 100644 --- a/app/assets/stylesheets/web/autoresize.css +++ b/app/assets/stylesheets/web/autoresize.css @@ -3,7 +3,7 @@ .autoresize__wrapper { display: grid !important; position: relative; - + > *, &::after { grid-area: 1 / 1 / 2 / 2; } @@ -12,6 +12,7 @@ content: attr(data-autoresize-clone-value) " "; font: inherit; line-height: inherit; + padding-block: var(--autosize-block-padding, 0); visibility: hidden; white-space: pre-wrap; } diff --git a/app/assets/stylesheets/web/base.css b/app/assets/stylesheets/web/base.css index 208c9d5d3..31c4180e1 100644 --- a/app/assets/stylesheets/web/base.css +++ b/app/assets/stylesheets/web/base.css @@ -53,7 +53,7 @@ } ::selection { - background-color: var(--color-selected); + background: var(--color-selected); @media (prefers-color-scheme: dark) { background-color: var(--color-selected-dark); diff --git a/app/assets/stylesheets/web/buttons.css b/app/assets/stylesheets/web/buttons.css index a4e7199e0..274d80ceb 100644 --- a/app/assets/stylesheets/web/buttons.css +++ b/app/assets/stylesheets/web/buttons.css @@ -104,7 +104,7 @@ } /* Make a normal button circular on mobile */ - @media (max-width: 640px) { + @media (max-width: 639px) { .btn--circle-mobile { aspect-ratio: 1; padding: 0.5em; diff --git a/app/assets/stylesheets/web/card-columns.css b/app/assets/stylesheets/web/card-columns.css index 501be0d3e..3b2faca60 100644 --- a/app/assets/stylesheets/web/card-columns.css +++ b/app/assets/stylesheets/web/card-columns.css @@ -38,8 +38,10 @@ min-block-size: 20lh; } - @media (max-width: 519px) { - display: none; + body:not(.public) & { + @media (max-width: 519px) { + display: none; + } } } @@ -486,7 +488,6 @@ } .card__title { - line-height: 1.2; pointer-events: none; } @@ -728,8 +729,10 @@ text-transform: uppercase; @media (hover: hover) { - &:hover { - --btn-background: color(from var(--card-color) srgb r g b / 0.15); + &:not(.card__column-name--current):hover { + --btn-background: color(from var(--column-color) srgb r g b / 0.15); + + color: var(--column-color); } } } diff --git a/app/assets/stylesheets/web/card-perma.css b/app/assets/stylesheets/web/card-perma.css index 0419e0548..150dde9a1 100644 --- a/app/assets/stylesheets/web/card-perma.css +++ b/app/assets/stylesheets/web/card-perma.css @@ -58,11 +58,27 @@ .card__title { font-size: clamp(var(--text-medium), 6vw, var(--text-x-large)); + margin-block-end: 0.5ch; + + /* With tight line spacing, Windows will cover over adjacent lines of text + * when input text is selected. Here, we're setting the selection color to a + * transparent value so the overlapping text lines are at least visible. */ + ::selection { + background: oklch(var(--lch-blue-light) / 0.5); + } + + &:has(textarea) { + margin-block-end: 0; + + @supports not (field-sizing: content) { + text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */ + } + } } .card__meta, .card__stages { - @media (min-width: 639px) { + @media (min-width: 640px) { font-size: var(--text-small); } } @@ -103,14 +119,14 @@ inset: calc(var(--bubble-size) / -4) calc(var(--bubble-size) / 1.5) auto auto; translate: 0 0; - @media (max-width: 800px) { + @media (max-width: 799px) { --bubble-size: 4.5rem; inset: calc(var(--bubble-size) / 1.5) 0 auto auto; } } - @media (max-width: 800px) { + @media (max-width: 799px) { --padding-inline: var(--main-padding); column-gap: 0; @@ -145,7 +161,7 @@ z-index: 1; } - &:has([data-controller="tooltip"]:hover) { + &:has([data-controller~="tooltip"]:hover) { z-index: var(--z-tooltip); } } @@ -153,7 +169,7 @@ .card-perma__actions--left { grid-area: actions-left; } .card-perma__actions--right { grid-area: actions-right; } - @media (max-width: 800px) { + @media (max-width: 799px) { .card-perma__actions { display: flex; padding-inline: var(--padding-inline); diff --git a/app/assets/stylesheets/web/cards.css b/app/assets/stylesheets/web/cards.css index de8666e70..9fe9c6601 100644 --- a/app/assets/stylesheets/web/cards.css +++ b/app/assets/stylesheets/web/cards.css @@ -143,6 +143,7 @@ } .card__title { + --autosize-block-padding: 0 0.5ch; --input-border-radius: 0; --input-color: var(--card-content-color); --lines: 3; @@ -150,18 +151,13 @@ color: var(--card-content-color); font-size: var(--text-xx-large); font-weight: 900; - line-height: 1.3; - /* We need a minimum line-height of 1.3 to avoid scrolling - in the card title input and cut-off descenders on Windows */ + line-height: 1.15; text-wrap: balance; - @supports not (field-sizing: content) { - &:has(textarea) { - text-wrap: unset; /* Safari is annoying if you have text-wrap: balance in textareas */ - } - } - .card-field__title { + overflow: hidden; /* prevent scrolling on windows */ + padding-block: var(--autosize-block-padding); + &:is(textarea)::placeholder { color: inherit; opacity: 0.66; @@ -181,7 +177,6 @@ lexxy-toolbar { border-block-start: 1px solid var(--color-ink-light); - margin-block-start: var(--block-space-half) !important; } & ~ .btn.btn--reversed { @@ -366,7 +361,6 @@ color: var(--stamp-color); font-size: 1.3em; font-weight: 900; - line-height: 1.2; position: relative; text-align: center; text-transform: uppercase; diff --git a/app/assets/stylesheets/web/comments.css b/app/assets/stylesheets/web/comments.css index 6720fa258..b58156f36 100644 --- a/app/assets/stylesheets/web/comments.css +++ b/app/assets/stylesheets/web/comments.css @@ -45,10 +45,6 @@ .comment__avatar { margin: calc(var(--comment-padding-block) * 0.75) calc(var(--comment-padding-inline) * -0.75); z-index: 0; - - .comment--mine_ & { - margin-inline: calc(var(--comment-padding-inline) * -0.75); - } } .comment__body { @@ -82,16 +78,6 @@ .comment__edit { background-color: var(--color-ink-lightest); - - .comment:not(.comment--mine) & { - display: none; - } - } - - .comment--mine { - .comment__reaction { - display: none; - } } .comment--system { diff --git a/app/assets/stylesheets/web/nav.css b/app/assets/stylesheets/web/nav.css index 2753a82c5..bcbe83088 100644 --- a/app/assets/stylesheets/web/nav.css +++ b/app/assets/stylesheets/web/nav.css @@ -102,6 +102,19 @@ } } + .nav__section { + border-block-start: 1px solid var(--color-ink-lighter); + font-size: var(--text-small); + + &[open] { + padding-block-end: 0.4rem; + } + } + + .nav__section--secret:not([data-is-filtering]) { + display: none; + } + .nav__header { --actions-width: 2rem; @@ -176,9 +189,8 @@ text-align: center; kbd { - border: none; - box-shadow: none; inset: 0.66em 0.33em auto auto; + line-height: 1.4; opacity: 0.5; position: absolute; diff --git a/app/assets/stylesheets/web/popup.css b/app/assets/stylesheets/web/popup.css index 6eb86837c..06add9439 100644 --- a/app/assets/stylesheets/web/popup.css +++ b/app/assets/stylesheets/web/popup.css @@ -5,7 +5,7 @@ --panel-padding: var(--block-space); --panel-size: auto; --popup-icon-size: 24px; - --popup-item-padding-inline: 0.5rem; + --popup-item-padding-inline: 0.4rem; --popup-display: flex; inset: 0 auto auto 50%; @@ -53,10 +53,10 @@ .popup__section-title { background: var(--color-canvas); font-size: var(--text-small); - font-weight: bold; + font-weight: 600; inset-block-start: 0; list-style: none; - padding: var(--block-space-half) var(--inline-space-half); + padding: 0.75ch var(--inline-space-half); position: sticky; text-transform: uppercase; z-index: 1; @@ -65,8 +65,7 @@ align-items: center; cursor: pointer; display: flex; - gap: 2ch; - justify-content: space-between; + gap: 0.5ch; } &::-webkit-details-marker { @@ -74,7 +73,8 @@ } .icon--caret-down { - opacity: 0.66; + font-size: 1ch; + margin-inline-start: -0.5ch; transition: rotate 150ms ease-out; } @@ -94,6 +94,10 @@ max-inline-size: 100%; padding: 0; row-gap: 1px; + + details & { + margin-inline-start: 0.75ch; + } } .popup__item { diff --git a/app/assets/stylesheets/web/reactions.css b/app/assets/stylesheets/web/reactions.css index 421c56d23..ed6187bae 100644 --- a/app/assets/stylesheets/web/reactions.css +++ b/app/assets/stylesheets/web/reactions.css @@ -190,7 +190,7 @@ gap: var(--inline-space-half); grid-template-columns: repeat(10, 1fr); - @media (max-width: 630px) { + @media (max-width: 639px) { grid-template-columns: repeat(6, 1fr); } diff --git a/app/assets/stylesheets/web/trays.css b/app/assets/stylesheets/web/trays.css index bb2538a7f..46c4b74b7 100644 --- a/app/assets/stylesheets/web/trays.css +++ b/app/assets/stylesheets/web/trays.css @@ -405,6 +405,10 @@ opacity: 1; } } + + [aria-busy] & { + position: absolute !important; + } } .avatar, diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb new file mode 100644 index 000000000..ab40286af --- /dev/null +++ b/app/controllers/account/exports_controller.rb @@ -0,0 +1,23 @@ +class Account::ExportsController < ApplicationController + before_action :ensure_export_limit_not_exceeded, only: :create + before_action :set_export, only: :show + + CURRENT_EXPORT_LIMIT = 10 + + def show + end + + def create + Current.account.exports.create!(user: Current.user).build_later + redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." + end + + private + def ensure_export_limit_not_exceeded + head :too_many_requests if Current.user.exports.current.count >= CURRENT_EXPORT_LIMIT + end + + def set_export + @export = Current.account.exports.completed.find_by(id: params[:id], user: Current.user) + end +end diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb index 92b49d3ce..6d508fc2c 100644 --- a/app/controllers/admin/stats_controller.rb +++ b/app/controllers/admin/stats_controller.rb @@ -14,5 +14,7 @@ class Admin::StatsController < AdminController .where("cards_count > 0") .order(cards_count: :desc) .limit(20) + + @recent_accounts = Account.order(created_at: :desc).limit(10) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7f1cb6565..87c3ab8dd 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,7 @@ class ApplicationController < ActionController::Base include Authentication include Authorization + include BlockSearchEngineIndexing include CurrentRequest, CurrentTimezone, SetPlatform include RequestForgeryProtection include TurboFlash, ViewTransitions diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 23f82dbd2..89eda5887 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -4,6 +4,7 @@ module Authentication included do before_action :require_account # Checking and setting account must happen first before_action :require_authentication + after_action :ensure_development_magic_link_not_leaked helper_method :authenticated? etag { Current.session.id if authenticated? } @@ -89,4 +90,16 @@ module Authentication Current.session.destroy cookies.delete(:session_token) end + + def ensure_development_magic_link_not_leaked + unless Rails.env.development? + raise "Leaking magic link via flash in #{Rails.env}?" if flash[:magic_link_code].present? + end + end + + def serve_development_magic_link(magic_link) + if Rails.env.development? + flash[:magic_link_code] = magic_link&.code + end + end end diff --git a/app/controllers/concerns/block_search_engine_indexing.rb b/app/controllers/concerns/block_search_engine_indexing.rb new file mode 100644 index 000000000..65bb50d2b --- /dev/null +++ b/app/controllers/concerns/block_search_engine_indexing.rb @@ -0,0 +1,14 @@ +# Tell crawlers like Googlebot to drop pages entirely from search results, even +# if other sites link to it +module BlockSearchEngineIndexing + extend ActiveSupport::Concern + + included do + after_action :block_search_engine_indexing + end + + private + def block_search_engine_indexing + headers["X-Robots-Tag"] = "none" + end +end diff --git a/app/controllers/join_codes_controller.rb b/app/controllers/join_codes_controller.rb index 7e7ded229..5b0977cd5 100644 --- a/app/controllers/join_codes_controller.rb +++ b/app/controllers/join_codes_controller.rb @@ -20,12 +20,7 @@ class JoinCodesController < ApplicationController elsif identity == Current.identity redirect_to new_users_join_url(script_name: @join_code.account.slug) else - terminate_session if Current.identity - - magic_link = identity.send_magic_link - flash[:magic_link_code] = magic_link&.code if Rails.env.development? - - session[:return_to_after_authenticating] = new_users_join_url(script_name: @join_code.account.slug) + logout_and_send_new_magic_link(identity) redirect_to session_magic_link_url(script_name: nil) end end @@ -42,4 +37,13 @@ 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/sessions_controller.rb b/app/controllers/sessions_controller.rb index 72131e61c..31ca0006f 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -11,7 +11,7 @@ class SessionsController < ApplicationController def create if identity = Identity.find_by_email_address(email_address) magic_link = identity.send_magic_link - flash[:magic_link_code] = magic_link&.code if Rails.env.development? + serve_development_magic_link(magic_link) end redirect_to session_magic_link_path diff --git a/app/controllers/signups_controller.rb b/app/controllers/signups_controller.rb index ea88383e0..33da02f28 100644 --- a/app/controllers/signups_controller.rb +++ b/app/controllers/signups_controller.rb @@ -1,12 +1,4 @@ class SignupsController < ApplicationController - # FIXME: Remove this before launch! - unless Rails.env.local? - http_basic_authenticate_with \ - name: Rails.application.credentials.account_signup_http_basic_auth.name, - password: Rails.application.credentials.account_signup_http_basic_auth.password, - realm: "Fizzy Signup" - end - disallow_account_scope allow_unauthenticated_access rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_path, alert: "Try again later." } diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0897e5b56..9733a7eec 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -22,7 +22,7 @@ module ApplicationHelper def back_link_to(label, url, action, **options) link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do - icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("←", class: "txt-x-small hide-on-touch").html_safe + icon_tag("arrow-left") + tag.strong("Back to #{label}", class: "overflow-ellipsis") + tag.kbd("ESC", class: "txt-x-small hide-on-touch").html_safe end end end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 54c09a407..a1960b341 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -1,6 +1,6 @@ module BoardsHelper def link_back_to_board(board) - back_link_to board.name, board, "keydown.left@document->hotkey#click click->turbo-navigation#backIfSamePath" + back_link_to board.name, board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click click->turbo-navigation#backIfSamePath" end def link_to_edit_board(board) diff --git a/app/helpers/cards_helper.rb b/app/helpers/cards_helper.rb index ba61cb94e..0dd93c520 100644 --- a/app/helpers/cards_helper.rb +++ b/app/helpers/cards_helper.rb @@ -39,9 +39,9 @@ module CardsHelper def card_social_tags(card) tag.meta(property: "og:title", content: "#{card.title} | #{card.board.name}") + - tag.meta(property: "og:description", content: format_excerpt(@card&.description, length: 200)) + - tag.meta(property: "og:image", content: @card.image.attached? ? "#{request.base_url}#{url_for(@card.image)}" : "#{request.base_url}/app-icon.png") + - tag.meta(property: "og:url", content: card_url(@card)) + tag.meta(property: "og:description", content: format_excerpt(card&.description, length: 200)) + + tag.meta(property: "og:image", content: card.image.attached? ? "#{request.base_url}#{url_for(card.image)}" : "#{request.base_url}/opengraph.png") + + tag.meta(property: "og:url", content: card_url(card)) end def button_to_remove_card_image(card) diff --git a/app/helpers/columns_helper.rb b/app/helpers/columns_helper.rb index 67c7cdca0..8a2e82e17 100644 --- a/app/helpers/columns_helper.rb +++ b/app/helpers/columns_helper.rb @@ -5,6 +5,7 @@ module ColumnsHelper card_triage_path(card, column_id: column), method: :post, class: [ "card__column-name btn", { "card__column-name--current": column == card.column && card.open? } ], + style: "--column-color: #{column.color}", form_class: "flex align-center gap-half", data: { turbo_frame: "_top" } end diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb index 6b8d20c04..6c3f01725 100644 --- a/app/helpers/filters_helper.rb +++ b/app/helpers/filters_helper.rb @@ -41,10 +41,10 @@ module FiltersHelper end def collapsible_nav_section(title, **properties, &block) - tag.details class: "popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do + tag.details class: "nav__section popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do concat(tag.summary(class: "popup__section-title") do - concat title concat icon_tag "caret-down" + concat title end) concat(tag.ul(class: "popup__list") do capture(&block) diff --git a/app/helpers/hotkeys_helper.rb b/app/helpers/hotkeys_helper.rb index 810a0f83e..062f6ca2d 100644 --- a/app/helpers/hotkeys_helper.rb +++ b/app/helpers/hotkeys_helper.rb @@ -6,8 +6,6 @@ module HotkeysHelper "⌘" elsif key == "enter" platform.mac? ? "return" : "enter" - elsif key == "shift" - "⇧" else key end.capitalize diff --git a/app/helpers/messages_helper.rb b/app/helpers/messages_helper.rb index 9dae7757c..f0e774882 100644 --- a/app/helpers/messages_helper.rb +++ b/app/helpers/messages_helper.rb @@ -3,10 +3,6 @@ module MessagesHelper turbo_frame_tag dom_id(card, :messages), class: "comments gap center", style: "--card-color: #{card.color}", - role: "group", aria: { label: "Messages" }, - data: { - controller: "created-by-current-user", - created_by_current_user_mine_class: "comment--mine" - }, & + role: "group", aria: { label: "Messages" }, & end end diff --git a/app/helpers/my/menu_helper.rb b/app/helpers/my/menu_helper.rb index fd1acd456..753bad6a2 100644 --- a/app/helpers/my/menu_helper.rb +++ b/app/helpers/my/menu_helper.rb @@ -25,7 +25,9 @@ module My::MenuHelper def my_menu_tag_item(the_tag) my_menu_item("tag", tag) do - link_to(tag.span(the_tag.title, class: "overflow-ellipsis"), cards_path(tag_ids: [ the_tag ]), class: "popup__btn btn") + link_to(tag.span(class: "overflow-ellipsis") do + tag.span("##{the_tag.title}", class: "visually-hidden") + the_tag.title + end, cards_path(tag_ids: [ the_tag ]), class: "popup__btn btn", title: "##{the_tag.title}") end end diff --git a/app/javascript/controllers/auto_click_controller.js b/app/javascript/controllers/auto_click_controller.js new file mode 100644 index 000000000..ebc6722b2 --- /dev/null +++ b/app/javascript/controllers/auto_click_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.click() + } +} diff --git a/app/javascript/controllers/created_by_current_user_controller.js b/app/javascript/controllers/created_by_current_user_controller.js deleted file mode 100644 index 8941fc120..000000000 --- a/app/javascript/controllers/created_by_current_user_controller.js +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static targets = [ "creation" ] - static classes = [ "mine" ] - - creationTargetConnected(element) { - if (element.dataset.creatorId == Current.user.id) { - element.classList.add(this.mineClass) - } - } -} diff --git a/app/javascript/controllers/magic_link_controller.js b/app/javascript/controllers/magic_link_controller.js index b2e487489..4d6c45ab5 100644 --- a/app/javascript/controllers/magic_link_controller.js +++ b/app/javascript/controllers/magic_link_controller.js @@ -4,16 +4,12 @@ import { onNextEventLoopTick } from "helpers/timing_helpers" export default class extends Controller { static targets = [ "input" ] - submit(event) { + submit() { onNextEventLoopTick(() => { - this.inputTarget.disabled = true - }) - } - - paste(event) { - onNextEventLoopTick(() => { - this.element.submit() - this.inputTarget.disabled = true + if (!this.inputTarget.disabled) { + this.element.submit() + this.inputTarget.disabled = true + } }) } } diff --git a/app/javascript/controllers/multi_selection_combobox_controller.js b/app/javascript/controllers/multi_selection_combobox_controller.js index 716420839..9f2937de5 100644 --- a/app/javascript/controllers/multi_selection_combobox_controller.js +++ b/app/javascript/controllers/multi_selection_combobox_controller.js @@ -26,12 +26,14 @@ export default class extends Controller { refresh() { this.labelTarget.textContent = this.#selectedLabel this.#updateHiddenFields() + this.#updateFilterShow() } clear(event) { this.#deselectAll() this.#updateHiddenFields() this.labelTarget.textContent = this.#selectedLabel + this.#updateFilterShow() } get #selectedLabel() { @@ -80,6 +82,7 @@ export default class extends Controller { #updateHiddenFields() { this.#clearHiddenFields() this.#addHiddenFields() + this.#updateFilterShow() } #deselectAll() { @@ -122,4 +125,9 @@ export default class extends Controller { this.element.appendChild(field) }) } + + #updateFilterShow() { + const hasSelection = this.#selectedValues().length > 0 + this.element.setAttribute("data-filter-show", hasSelection) + } } diff --git a/app/javascript/controllers/reaction_emoji_controller.js b/app/javascript/controllers/reaction_emoji_controller.js index 2d2d5bbca..d0ebd47fa 100644 --- a/app/javascript/controllers/reaction_emoji_controller.js +++ b/app/javascript/controllers/reaction_emoji_controller.js @@ -6,7 +6,12 @@ export default class extends Controller { insertEmoji(event) { const emojiChar = event.target.getAttribute("data-emoji") const value = this.inputTarget.value - this.inputTarget.value = `${value}${emojiChar}` + const newValue = `${value}${emojiChar}` + + if (this.inputTarget.maxLength > 0 && newValue.length <= this.inputTarget.maxLength) { + this.inputTarget.value = newValue + } + this.inputTarget.focus() } } diff --git a/app/jobs/export_account_data_job.rb b/app/jobs/export_account_data_job.rb new file mode 100644 index 000000000..82b5071cb --- /dev/null +++ b/app/jobs/export_account_data_job.rb @@ -0,0 +1,7 @@ +class ExportAccountDataJob < ApplicationJob + queue_as :backend + + def perform(export) + export.build + end +end diff --git a/app/mailers/export_mailer.rb b/app/mailers/export_mailer.rb new file mode 100644 index 000000000..5385aeed6 --- /dev/null +++ b/app/mailers/export_mailer.rb @@ -0,0 +1,8 @@ +class ExportMailer < ApplicationMailer + def completed(export) + @export = export + @user = export.user + + mail to: @user.identity.email_address, subject: "Your Fizzy data export is ready for download" + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 9c445f10e..f74660cbb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -8,6 +8,7 @@ class Account < ApplicationRecord has_many :webhooks, dependent: :destroy has_many :tags, dependent: :destroy has_many :columns, dependent: :destroy + has_many :exports, class_name: "Account::Export", dependent: :destroy has_many_attached :uploads @@ -26,7 +27,7 @@ class Account < ApplicationRecord end def slug - "/#{external_account_id}" + "/#{AccountSlug.encode(external_account_id)}" end def account diff --git a/app/models/account/export.rb b/app/models/account/export.rb new file mode 100644 index 000000000..c5e3d64c3 --- /dev/null +++ b/app/models/account/export.rb @@ -0,0 +1,73 @@ +class Account::Export < ApplicationRecord + belongs_to :account + belongs_to :user + + has_one_attached :file + + enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending + + scope :current, -> { where(created_at: 24.hours.ago..) } + scope :expired, -> { where(completed_at: ...24.hours.ago) } + + def self.cleanup + expired.destroy_all + end + + def build_later + ExportAccountDataJob.perform_later(self) + end + + def build + processing! + zipfile = generate_zip + + file.attach io: File.open(zipfile.path), filename: "fizzy-export-#{id}.zip", content_type: "application/zip" + mark_completed + + ExportMailer.completed(self).deliver_later + rescue => e + update!(status: :failed) + raise + ensure + zipfile&.close + zipfile&.unlink + end + + def mark_completed + update!(status: :completed, completed_at: Time.current) + end + + private + def generate_zip + Tempfile.new([ "export", ".zip" ]).tap do |tempfile| + Zip::File.open(tempfile.path, create: true) do |zip| + exportable_cards.find_each do |card| + add_card_to_zip(zip, card) + end + end + end + end + + def exportable_cards + user.accessible_cards.includes( + :board, + creator: :identity, + comments: { creator: :identity }, + rich_text_description: { embeds_attachments: :blob } + ) + end + + def add_card_to_zip(zip, card) + zip.get_output_stream("#{card.number}.json") do |f| + f.write(card.export_json) + end + + card.export_attachments.each do |attachment| + zip.get_output_stream(attachment[:path], compression_method: Zip::Entry::STORED) do |f| + attachment[:blob].download { |chunk| f.write(chunk) } + end + rescue ActiveStorage::FileNotFoundError + # Skip attachments where the file is missing from storage + end + end +end diff --git a/app/models/account/seeder.rb b/app/models/account/seeder.rb index 9858cff6b..354674ddc 100644 --- a/app/models/account/seeder.rb +++ b/app/models/account/seeder.rb @@ -30,43 +30,36 @@ class Account::Seeder # Cards playground.cards.create! creator: creator, title: "Finally, watch this Fizzy orientation video", status: "published", description: <<~HTML
There’s a whole lot more you can do in Fizzy. In the video below 37signals founder and CEO, Jason Fried, will walk you through the basics in just 8 minutes.
-Open Fizzy menu, select “+ Add people”, then copy the invite link. You can give this link to someone else so they can make an login for themselves in your account.
-Hit “1” or pull down the Fizzy menu and select “Home”.
-Pull down the Fizzy menu at the top of the screen, and select “Assigned to me” or just hit “2” on your keyboard any time.
-The Fizzy menu is how you get around the app. Click “Fizzy” at the top of the screen or hit the “J” key on your keyboard to pop it open.
-Click the little head with the + next to it, pick yourself.
-Click the little Tag icon, type “design”, then “Create tag”. Then, move the card to the new “YES” column you created in the previous step.
-Go back to the Board view, click the little “+” to the right of the DONE column, name the column, pick a color, then do it again.
-After that, drag this card to “DONE” or select “DONE” in the sidebar.
-Your export is ready. The download should start automatically.
+ + <%= link_to rails_blob_path(@export.file, disposition: "attachment"), + id: "download-link", + class: "btn btn--link", + data: { turbo: false, controller: "auto-click" } do %> + Download your data + <% end %> + <% else %> +That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-lnk" %>.
+ <% end %> + +Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for specific period of time. This is the default, global setting — you can override it on each board.
-Fizzy doesn’t let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for specific period of time. This is the default, global setting — you can override it on each board.
+Download an archive of your Fizzy data.
+