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.

-


HTML playground.cards.create! creator: creator, title: "Now, grab the invite link to invite someone else", status: "published", description: <<~HTML

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.

-


HTML playground.cards.create! creator: creator, title: "Then, head back home to check out activity", status: "published", description: <<~HTML

Hit “1” or pull down the Fizzy menu and select “Home”.

-


HTML playground.cards.create! creator: creator, title: "Now,check out all cards assigned to you", status: "published", description: <<~HTML

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.

-


HTML playground.cards.create! creator: creator, title: "Then, open the Fizzy menu", status: "published", description: <<~HTML

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.

-


HTML playground.cards.create! creator: creator, title: "Next, assign this card to yourself", status: "published", description: <<~HTML

Click the little head with the + next to it, pick yourself.

-


HTML playground.cards.create! creator: creator, title: "Now, tag this card “Design” then move it to YES", status: "published", description: <<~HTML

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.

-


HTML @@ -75,11 +68,8 @@ class Account::Seeder
  • Make one called "Yes"
  • Make another called "Working on"
  • -


    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.

    -


    HTML @@ -93,9 +83,8 @@ class Account::Seeder
    1. Click the title and you can rename the card, change the description, or add more information to the card.
    2. Then, hit "Mark as Done" at the bottom of the card.
    3. -
    4. Finally, hit “←Playground” in the top left of the screen to go back to the board.
    5. +
    6. Finally, hit “Back to Playground” in the top left of the screen to go back to the board.
    -


    HTML end diff --git a/app/models/board/broadcastable.rb b/app/models/board/broadcastable.rb index 177509800..f5481a9ff 100644 --- a/app/models/board/broadcastable.rb +++ b/app/models/board/broadcastable.rb @@ -3,6 +3,6 @@ module Board::Broadcastable included do broadcasts_refreshes - broadcasts_refreshes_to ->(_) { :all_boards } + broadcasts_refreshes_to ->(board) { [ board.account, :all_boards ] } end end diff --git a/app/models/card.rb b/app/models/card.rb index 0a589d0b7..dfad00cb8 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,6 +1,6 @@ class Card < ApplicationRecord include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, - Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, + Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Taggable, Triageable, Watchable belongs_to :account, default: -> { board.account } @@ -16,6 +16,7 @@ class Card < ApplicationRecord before_create :assign_number after_save -> { board.touch }, if: :published? + after_touch -> { board.touch }, if: :published? after_update :handle_board_change, if: :saved_change_to_board_id? scope :reverse_chronologically, -> { order created_at: :desc, id: :desc } diff --git a/app/models/card/exportable.rb b/app/models/card/exportable.rb new file mode 100644 index 000000000..ffff82581 --- /dev/null +++ b/app/models/card/exportable.rb @@ -0,0 +1,85 @@ +module Card::Exportable + extend ActiveSupport::Concern + include ActionView::Helpers::TagHelper + + def export_json + JSON.pretty_generate({ + number: number, + title: title, + board: board.name, + status: export_status, + creator: export_user(creator), + description: export_html(description), + created_at: created_at.iso8601, + updated_at: updated_at.iso8601, + comments: comments.chronologically.map do |comment| + { + id: comment.id, + body: export_html(comment.body), + creator: export_user(comment.creator), + created_at: comment.created_at.iso8601 + } + end + }) + end + + def export_attachments + collect_attachments.map do |attachment| + { path: export_attachment_path(attachment.blob), blob: attachment.blob } + end + end + + private + def export_html(rich_text) + return "" if rich_text.blank? + + rich_text.body.render_attachments do |attachment| + attachment_representation(attachment) + end.to_html + end + + def attachment_representation(attachment) + case attachable = attachment.attachable + when ActiveStorage::Blob + path = export_attachment_path(attachable) + if attachable.image? + tag.img(src: path, alt: attachable.filename) + else + tag.a(attachable.filename, href: path) + end + when ActionText::Attachables::RemoteImage + tag.img(src: attachable.url, alt: "Remote image") + else + attachment.to_html + end + end + + def export_user(user) + { + id: user.id, + name: user.name, + email: user.identity&.email_address + } + end + + def export_attachment_path(blob) + "#{number}/#{blob.key}_#{blob.filename}" + end + + def collect_attachments + attachments.to_a + comments.flat_map { |c| c.attachments.to_a } + end + + def export_status + case + when closed? + "Done" + when postponed? + "Not now" + when column.present? + column.name + else + "Maybe?" + end + end +end diff --git a/app/models/card/mentions.rb b/app/models/card/mentions.rb index 3ce74726b..11e35b97c 100644 --- a/app/models/card/mentions.rb +++ b/app/models/card/mentions.rb @@ -9,7 +9,7 @@ module Card::Mentions end def should_check_mentions? - saved_change_to_status? && published? + was_just_published? end end end diff --git a/app/models/card/statuses.rb b/app/models/card/statuses.rb index d339acd17..53b243e4e 100644 --- a/app/models/card/statuses.rb +++ b/app/models/card/statuses.rb @@ -4,7 +4,10 @@ module Card::Statuses included do enum :status, %w[ drafted published ].index_by(&:itself) + attr_reader :initial_status + before_save :update_created_at_on_publication + before_save :remember_initial_status after_create -> { track_event :published }, if: :published? scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) } @@ -17,10 +20,21 @@ module Card::Statuses end end + def was_just_published? + initial_status&.drafted? && status_in_database.inquiry.published? + end + private def update_created_at_on_publication if will_save_change_to_status? && status_in_database.inquiry.drafted? self.created_at = Time.current end end + + # So that we can check it in callbacks when other operations in the transaction clean the changes. + def remember_initial_status + if will_save_change_to_status? + @initial_status ||= status_in_database.to_s.inquiry + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 90aab0338..d70a260ea 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,6 +16,7 @@ class User < ApplicationRecord has_many :closures, dependent: :nullify has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card + has_many :exports, class_name: "Account::Export", dependent: :destroy scope :with_avatars, -> { preload(:account, :avatar_attachment) } @@ -23,10 +24,16 @@ class User < ApplicationRecord transaction do accesses.destroy_all update! active: false, identity: nil + close_remote_connections end end def setup? name != identity.email_address end + + private + def close_remote_connections + ActionCable.server.remote_connections.where(current_user: self).disconnect(reconnect: false) + end end diff --git a/app/models/webhook/delivery.rb b/app/models/webhook/delivery.rb index 05768e04a..ce69c01ad 100644 --- a/app/models/webhook/delivery.rb +++ b/app/models/webhook/delivery.rb @@ -4,8 +4,6 @@ class Webhook::Delivery < ApplicationRecord ENDPOINT_TIMEOUT = 7.seconds DNS_RESOLUTION_TIMEOUT = 2 DISALLOWED_IP_RANGES = [ - # IPv4 mapped to IPv6 - IPAddr.new("::ffff:0:0/96"), # Broadcasts IPAddr.new("0.0.0.0/8") ].freeze @@ -85,7 +83,7 @@ class Webhook::Delivery < ApplicationRecord end ip_addresses.any? do |ip| - ip.private? || ip.loopback? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) } + ip.private? || ip.loopback? || ip.link_local? || ip.ipv4_mapped? || DISALLOWED_IP_RANGES.any? { |range| range.include?(ip) } end end diff --git a/app/views/account/exports/show.html.erb b/app/views/account/exports/show.html.erb new file mode 100644 index 000000000..f01c13dc5 --- /dev/null +++ b/app/views/account/exports/show.html.erb @@ -0,0 +1,29 @@ +<% if @export.present? %> + <% @page_title = "Download Export" %> +<% else %> + <% @page_title = "Download Expired" %> +<% end %> + +<% content_for :header do %> +
    + <%= back_link_to "Account Settings", account_settings_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
    +<% end %> + +
    +

    <%= @page_title %>

    + + <% if @export.present? %> +

    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 %> + +
    diff --git a/app/views/account/join_codes/edit.html.erb b/app/views/account/join_codes/edit.html.erb index 7f22b9318..bca92e58f 100644 --- a/app/views/account/join_codes/edit.html.erb +++ b/app/views/account/join_codes/edit.html.erb @@ -2,13 +2,7 @@ <% content_for :header do %>
    - <%= link_to account_join_code_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> - - <%= icon_tag "arrow-left" %> - Back to Invite link - - - <% end %> + <%= back_link_to "Invite link", account_join_code_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
    <% end %> diff --git a/app/views/account/join_codes/show.html.erb b/app/views/account/join_codes/show.html.erb index e9fd72d71..f3cfb1e37 100644 --- a/app/views/account/join_codes/show.html.erb +++ b/app/views/account/join_codes/show.html.erb @@ -2,13 +2,7 @@ <% content_for :header do %>
    - <%= link_to account_settings_path, class: "btn btn--back borderless txt-medium", data: { controller: "hotkey", action: "keydown.left@document->hotkey#click" } do %> - - <%= icon_tag "arrow-left" %> - Back to Account Settings - - - <% end %> + <%= back_link_to "Account Settings", account_settings_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
    <% end %> diff --git a/app/views/account/settings/_entropy.html.erb b/app/views/account/settings/_entropy.html.erb index ec9c58302..53f5064ce 100644 --- a/app/views/account/settings/_entropy.html.erb +++ b/app/views/account/settings/_entropy.html.erb @@ -1,8 +1,7 @@ -
    -
    -

    Auto close

    -

    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.

    -
    +
    +

    Auto close

    +

    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.

    +
    + +<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %> - <%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %> -
    diff --git a/app/views/account/settings/_export.html.erb b/app/views/account/settings/_export.html.erb new file mode 100644 index 000000000..d44bb4b74 --- /dev/null +++ b/app/views/account/settings/_export.html.erb @@ -0,0 +1,19 @@ +
    +

    Export your data

    +

    Download an archive of your Fizzy data.

    +
    + +
    + + + +

    Export your account data

    +

    This will kick off a request to generate a ZIP archive of all the data in boards you have access to.

    +

    When the file is ready, we’ll email you a link to download it. The link will expire after 24 hours.

    + +
    + <%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %> + +
    +
    +
    \ No newline at end of file diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 57f3913bf..273a07b9a 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -15,5 +15,8 @@ <%= render "account/settings/users", users: @users %> - <%= render "account/settings/entropy", account: @account %> +
    + <%= render "account/settings/entropy", account: @account %> + <%= render "account/settings/export" %> +
    diff --git a/app/views/admin/stats/show.html.erb b/app/views/admin/stats/show.html.erb index a3ae18c55..7c7491d2a 100644 --- a/app/views/admin/stats/show.html.erb +++ b/app/views/admin/stats/show.html.erb @@ -61,6 +61,37 @@ +
    +
    +

    + 10 Most Recent Signups +

    +
    + + +
    +

    diff --git a/app/views/boards/edit/_publication.html.erb b/app/views/boards/edit/_publication.html.erb index 8ff98d87e..7cfc10ca6 100644 --- a/app/views/boards/edit/_publication.html.erb +++ b/app/views/boards/edit/_publication.html.erb @@ -34,7 +34,7 @@ placeholder: "Add a public note about this board…", data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" }, readonly: !Current.user.can_administer_board?(@board) %> - <%= form.button "Save changes", type: :submit, class: "btn txt-small" %> + <%= form.button "Save changes", type: :submit, class: "btn txt-small", title: "Save changes (#{ hotkey_label(["ctrl", "enter"]) })" %> <% end %>

    diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb index 57c809b17..4536af7fc 100644 --- a/app/views/boards/new.html.erb +++ b/app/views/boards/new.html.erb @@ -2,7 +2,7 @@
    <%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form" } do |form| %> -

    <%= @page_title %>

    +

    <%= @page_title %>

    <%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off",placeholder: "Name it…", data: { action: "keydown.esc@document->form#cancel" } %>