Merge branch 'main' into dialog-ux-improvements

This commit is contained in:
Alexander Zaytsev
2026-02-05 17:46:33 +01:00
161 changed files with 2876 additions and 499 deletions
+7
View File
@@ -137,6 +137,13 @@ Key recurring tasks (via `config/recurring.yml`):
- Search records denormalized for performance
- Models in `app/models/search/`
### Imports and exports
Allow people to move between OSS and SAAS Fizzy instances:
- Exports/Imports can be wirtten to/read from local or S3 storage depending on the config of the instance (both myst be supported)
- Must be able to handle very large ZIP files (500+GB)
- Models in `app/models/account/data_transfer/`, `app/models/zip_file`
## Tools
### Chrome MCP (Local Dev)
+1 -1
View File
@@ -34,7 +34,7 @@ gem "platform_agent"
gem "aws-sdk-s3", require: false
gem "web-push"
gem "net-http-persistent"
gem "rubyzip", require: "zip"
gem "zip_kit"
gem "mittens"
gem "useragent", bc: "useragent"
+2 -1
View File
@@ -479,6 +479,7 @@ GEM
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.4)
zip_kit (6.3.4)
PLATFORMS
aarch64-linux
@@ -523,7 +524,6 @@ DEPENDENCIES
rouge
rqrcode
rubocop-rails-omakase
rubyzip
selenium-webdriver
solid_cable (>= 3.0)
solid_cache (~> 1.0)
@@ -538,6 +538,7 @@ DEPENDENCIES
web-console
web-push
webmock
zip_kit
BUNDLED WITH
2.7.2
+1 -1
View File
@@ -8,7 +8,7 @@ gem "stripe", "~> 18.0"
gem "queenbee", bc: "queenbee-plugin"
gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
gem "audits1984", bc: "audits1984"
gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api"
# Telemetry
gem "rails_structured_logging", bc: "rails-structured-logging"
+5 -2
View File
@@ -1,10 +1,12 @@
GIT
remote: https://github.com/basecamp/audits1984
revision: c790eaec0716c8df56b3d0ab5bf5d75e3e1b0ed0
revision: 422b8ff25820c828b7cf09aff4923fd8cc2c6795
branch: flavorjones/coworker-api
specs:
audits1984 (0.1.7)
console1984
importmap-rails (>= 1.2.1)
jbuilder
rinku
rouge
turbo-rails
@@ -626,6 +628,7 @@ GEM
railties
yabeda (~> 0.8)
zeitwerk (2.7.4)
zip_kit (6.3.4)
PLATFORMS
aarch64-linux
@@ -676,7 +679,6 @@ DEPENDENCIES
rouge
rqrcode
rubocop-rails-omakase
rubyzip
selenium-webdriver
sentry-rails
sentry-ruby
@@ -703,6 +705,7 @@ DEPENDENCIES
yabeda-prometheus-mmap
yabeda-puma-plugin
yabeda-rails
zip_kit
BUNDLED WITH
2.7.2
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23 0L1.00002 0C0.194222 0 -0.283458 0.955359 0.200023 1.6L9.00002 13.423V22C9.01178 23.585 10.9006 24.5769 12.2 23.6L14.2 22.1C14.696 21.7267 14.9989 21.1208 15 20.5V13.423L23.8 1.6C24.2835 0.955343 23.8058 2.70383e-05 23 0Z" fill="black"/></svg>

After

Width:  |  Height:  |  Size: 317 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 17"><path d="m11.5 10.3-.6-.2c-.2-.5-.3-1.1-.1-1.6 1.1-1.2 1.6-2.7 1.5-4.3 0-2.4-1.6-4.2-3.8-4.2S4.7 1.8 4.7 4.2c-.1 1.5.4 3.1 1.5 4.3.2.5.2 1.1-.1 1.6l-.6.2c-2.4.9-4.2 1.5-4.8 2.6-.4 1.2-.7 2.4-.7 3.6 0 .3.2.5.5.5h16c.3 0 .5-.2.5-.5 0-1.2-.3-2.4-.7-3.5-.6-1.1-2.4-1.8-4.8-2.7"/></svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m15.5294 14.1176-.7059-.2352c-.2353-.5883-.3529-1.2942-.1176-1.8824 1.2941-1.4118 1.8823-3.17647 1.7647-5.05882 0-2.82353-1.8824-4.94118-4.4706-4.94118-2.58824 0-4.47059 2.11765-4.47059 4.94118-.11765 1.7647.47059 3.64702 1.76471 5.05882.23529.5882.23529 1.2941-.11765 1.8824l-.70588.2352c-2.82353 1.0589-4.94118 1.7648-5.64706 3.0589-.47059 1.4117-.82353 2.8235-.82353 4.2353 0 .3529.23529.5882.58824.5882h18.82356c.3529 0 .5882-.2353.5882-.5882 0-1.4118-.3529-2.8236-.8235-4.1177-.7059-1.2941-2.8236-2.1176-5.6471-3.1765z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 614 B

+6
View File
@@ -1,6 +1,12 @@
@layer reset, base, components, modules, utilities, native, platform;
:root {
/* Insets - The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */
--custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px));
--custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px));
--custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px));
--custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px));
/* Spacing */
--inline-space: 1ch;
--inline-space-half: calc(var(--inline-space) / 2);
+7
View File
@@ -3,5 +3,12 @@
.hide-on-android {
display: none;
}
/* Filters
/* ------------------------------------------------------------------------ */
.filters {
--text-x-small: 1rem;
}
}
}
+3 -10
View File
@@ -107,11 +107,6 @@
> * {
grid-area: 1/1;
}
@media (max-width: 639px) {
--btn-size: 3em;
--icon-size: 75%;
}
}
/* Make a normal button circular on mobile */
@@ -125,7 +120,7 @@
inline-size: var(--btn-size);
kbd,
span:last-of-type {
span:last-of-type:not(.icon) {
display: none;
}
}
@@ -224,17 +219,15 @@
.btn--back {
--btn-border-size: 0;
font-size: var(--text-medium);
@media (max-width: 639px) {
padding: 0.5em;
strong, kbd {
display: none;
}
}
@media (min-width: 640px) {
font-size: var(--text-medium);
.icon--arrow-left {
display: none;
}
+1 -1
View File
@@ -232,7 +232,7 @@
overflow-y: auto;
.is-expanded & {
padding: var(--column-padding);
padding: var(--column-padding) var(--column-padding) calc(var(--column-padding) + var(--custom-safe-inset-bottom));
/* Use the rest of the column height for scrolling */
@media (max-width: 639px) {
+2
View File
@@ -62,6 +62,8 @@
}
.card__board {
align-items: center;
align-self: start;
background-color: var(--card-color);
border-radius: var(--border-radius) 0 var(--border-radius) 0;
color: var(--color-ink-inverted);
+20 -17
View File
@@ -220,14 +220,14 @@
.events__column-header {
background-color: var(--color-canvas);
grid-row-start: 1;
inset-block-start: calc(var(--block-space) * -1);
margin-block-end: var(--events-gap);
padding-block: calc(var(--events-gap) * 3) var(--events-gap);
inset-block-start: var(--custom-safe-inset-top);
margin-block: calc(var(--events-gap) * 2) var(--events-gap);
padding-block: var(--events-gap);
position: sticky;
z-index: var(--z-events-column-header);
@media (max-width: 639px) {
margin-inline: calc(var(--main-padding) * -1);
margin-inline: calc(var(--main-padding) * -0.5);
padding-inline: var(--main-padding);
}
}
@@ -239,12 +239,16 @@
}
.events__maximize-button {
inset: calc(var(--events-gap) * 3) 0 auto auto;
inset: 50% var(--events-gap) auto auto;
outline-offset: -2px;
position: absolute;
transform: translateY(-10%);
transform: translateY(-50%);
z-index: 1;
@media (max-width: 639px) {
inset-inline-end: 0;
}
@media (any-hover: hover ) {
opacity: 0;
@@ -287,6 +291,7 @@
.event {
--column-gap: 0.7ch;
--event-padding: 0.6em;
background-color: color-mix(in srgb, var(--card-color) 10%, var(--color-canvas));
border-radius: 0.2em;
@@ -296,7 +301,7 @@
max-inline-size: 100%;
min-inline-size: 0;
overflow: clip;
padding: 0.6em;
padding: var(--event-padding);
position: relative;
z-index: 0;
@@ -321,12 +326,15 @@
z-index: 0;
}
.card__header {
inline-size: 100%;
margin-block-start: calc(-1 * var(--event-padding));
}
.card__board {
background-color: transparent;
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
font-size: 0.7em;
padding: 0;
translate: 0 -0.3em;
}
.card__board-name {
@@ -373,16 +381,11 @@
}
.event__icon {
color: var(--card-color);
display: grid;
margin: -0.3em -0.3em auto auto;
margin-inline-start: auto;
place-content: center;
.icon {
background-color: var(--card-color);
block-size: 1em;
inline-size: 1em;
grid-area: 1 / 1;
}
translate: calc(var(--event-padding) / 2);
}
.event__timestamp {
+13
View File
@@ -0,0 +1,13 @@
@layer components {
.expandable-on-native {
body:not([data-platform~=native]) & {
&::details-content {
display: contents;
}
summary {
display: none;
}
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
"menu menu menu"
"actions-start title actions-end";
max-inline-size: 100dvw;
padding-block: calc(var(--block-space-half) + env(safe-area-inset-top)) var(--block-space-half);
padding-block: calc(var(--block-space-half) + var(--custom-safe-inset-top)) var(--block-space-half);
padding-inline: var(--main-padding);
position: relative;
z-index: var(--z-nav);
+35 -13
View File
@@ -57,18 +57,12 @@
}
}
.input--file {
.input--file,
.input--upload {
cursor: pointer;
display: grid;
inline-size: auto;
place-items: center;
> * {
grid-area: 1 / 1;
}
img {
border-radius: 0.4em;
&:has(input[type="file"]:focus-visible) {
outline: 0.15rem solid var(--color-selected-dark);
}
input[type="file"] {
@@ -88,10 +82,19 @@
opacity: 0;
}
}
}
&:has(input[type="file"]:focus),
&:has(input[type="file"]:focus-visible) {
outline: 0.15rem solid var(--color-selected-dark);
.input--file {
display: grid;
inline-size: auto;
place-items: center;
> * {
grid-area: 1 / 1;
}
img {
border-radius: 0.4em;
}
&:is(.avatar) {
@@ -101,6 +104,25 @@
}
}
.input--upload {
--btn-border-color: var(--color-ink);
--btn-border-radius: 1ch;
border-style: dashed;
position: relative;
input[type="file"] {
inset: 0;
outline: none;
position: absolute;
}
&:has([data-upload-preview-target="fileName"]:not([hidden])) {
--btn-border-color: var(--color-positive);
--btn-color: var(--color-positive);
}
}
.input--select {
--input-border-radius: 2em;
--input-padding: 0.5em 1.8em 0.5em 1.2em;
+20
View File
@@ -1,7 +1,27 @@
@layer platform {
:root:has([data-platform~=ios]) {
&[data-text-size=xsmall] { font-size: 14px; }
&[data-text-size=small] { font-size: 15px; }
&[data-text-size=medium] { font-size: 16px; }
&[data-text-size=large] { font-size: 17px; }
&[data-text-size=xlarge] { font-size: 19px; }
&[data-text-size=xxlarge] { font-size: 21px; }
&[data-text-size=xxxlarge] { font-size: 23px; }
}
[data-platform~=ios] {
.hide-on-ios {
display: none;
}
/* Events
/* ------------------------------------------------------------------------ */
.events__column-header {
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
background-color: oklch(from var(--color-canvas) l c h / 0.5);
border-radius: 10em;
}
}
}
+9 -2
View File
@@ -6,6 +6,13 @@
&.public {
grid-template-rows: auto 1fr auto;
}
&.compact-on-touch {
@media (any-hover: none) {
grid-template-rows: auto 1fr auto;
min-height: unset;
}
}
}
/* Required for the card column page on mobile, but not needed otherwise */
@@ -23,8 +30,8 @@
margin-inline: auto;
max-inline-size: 100dvw;
padding-inline:
calc(var(--main-padding) + env(safe-area-inset-left))
calc(var(--main-padding) + env(safe-area-inset-right));
calc(var(--main-padding) + var(--custom-safe-inset-left))
calc(var(--main-padding) + var(--custom-safe-inset-right));
text-align: center;
}
+17 -3
View File
@@ -12,7 +12,11 @@
max-height: unset;
max-width: unset;
overflow: hidden;
padding: var(--lightbox-padding);
padding:
calc(var(--lightbox-padding) + var(--custom-safe-inset-top))
calc(var(--lightbox-padding) + var(--custom-safe-inset-right))
calc(var(--lightbox-padding) + var(--custom-safe-inset-bottom))
calc(var(--lightbox-padding) + var(--custom-safe-inset-left));
text-align: center;
&::backdrop {
@@ -32,6 +36,9 @@
/* Open state */
&[open],
&[open]::backdrop {
align-items: center;
display: flex;
justify-content: center;
opacity: 1;
@starting-style {
@@ -47,12 +54,15 @@
.lightbox__actions {
display: flex;
gap: 1ch;
inset: var(--lightbox-padding) var(--lightbox-padding) auto auto;
inset:
calc(var(--lightbox-padding) + var(--custom-safe-inset-top))
calc(var(--lightbox-padding) + var(--custom-safe-inset-right))
auto
auto;
position: absolute;
}
.lightbox__figure {
align-self: stretch;
animation-fill-mode: forwards;
animation: slide-down var(--dialog-duration);
display: flex;
@@ -72,6 +82,10 @@
&:empty {
display: none;
}
&[tabindex="-1"]:focus-visible {
outline: unset;
}
}
.lightbox__image {
+20 -7
View File
@@ -1,11 +1,5 @@
@layer native {
[data-platform~=native] {
/* The mobile apps may inject their own custom insets based on native elements on screen, like a floating navigation */
--custom-safe-inset-top: var(--injected-safe-inset-top, env(safe-area-inset-top, 0px));
--custom-safe-inset-right: var(--injected-safe-inset-right, env(safe-area-inset-right, 0px));
--custom-safe-inset-bottom: var(--injected-safe-inset-bottom, env(safe-area-inset-bottom, 0px));
--custom-safe-inset-left: var(--injected-safe-inset-left, env(safe-area-inset-left, 0px));
--footer-height: 0;
-webkit-tap-highlight-color: transparent;
@@ -14,6 +8,15 @@
display: none;
}
/* Layout
/* ------------------------------------------------------------------------ */
&:not(.contained-scrolling) {
#main {
padding-block-end: var(--custom-safe-inset-bottom);
}
}
/* Header
/* ------------------------------------------------------------------------ */
@@ -21,7 +24,12 @@
:not(:has(.header__title, .header__actions)),
:not(:has(.header__title, .header__actions--end)):has(.header__actions--start .btn--back:only-child)
) {
display: none;
block-size: var(--custom-safe-inset-top);
padding: unset;
* {
display: none;
}
}
.header__actions {
@@ -48,6 +56,11 @@
}
}
.card-perma__bg {
border-start-start-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block)));
border-start-end-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block)));
}
.card-perma__closure-message {
margin-block: var(--block-space);
translate: unset;
+2 -2
View File
@@ -95,12 +95,12 @@
display: none;
.notifications--on & {
display: block;
display: inline;
}
}
.notifications__off-message {
display: block;
display: inline;
.notifications--on & {
display: none;
+2 -2
View File
@@ -27,13 +27,13 @@
&:where(.popup--align-left),
&.orient-left {
inset-inline: auto 0;
transform: translateX(0);
transform: translateX(var(--orient-offset, 0px));
}
&:where(.popup--align-right),
&.orient-right {
inset-inline: 0 auto;
transform: translateX(0);
transform: translateX(var(--orient-offset, 0px));
}
form {
-16
View File
@@ -1,16 +0,0 @@
@layer components {
.profile-layout {
display: flex;
gap: var(--inline-space);
@media (min-width: 800px) {
align-items: stretch;
justify-content: center;
}
@media (max-width: 799px) {
align-items: center;
flex-direction: column;
}
}
}
+23 -4
View File
@@ -13,29 +13,48 @@
}
}
/* Sections & Panels
/* -------------------------------------------------------------------------- */
.settings__panel {
--panel-size: 100%;
--panel-padding: calc(var(--settings-spacer) / 1);
display: flex;
flex-direction: column;
gap: calc(var(--settings-spacer) / 2);
gap: var(--panel-padding);
min-block-size: 100%;
min-inline-size: 0;
@media (min-width: 960px) {
--panel-padding: calc(var(--settings-spacer) * 1.5) calc(var(--settings-spacer) * 2);
@media (min-width: 640px) {
--panel-padding: calc(var(--settings-spacer) * 2);
}
}
.settings__panel--users {
max-height: 80dvh;
@media (min-width: 640px) {
max-height: 80dvh;
}
@media (min-width: 960px) {
max-height: calc(100dvh - 12rem);
}
}
.settings__section {
h2 {
font-size: var(--text-large);
}
> * + * {
margin-block-start: calc(var(--panel-padding) / 2);
}
&:is(:first-child):has(h2) {
margin-top: -0.33lh; /* Align h2 letters caps with panel padding */
}
}
/* Users
/* ------------------------------------------------------------------------ */
+2 -2
View File
@@ -20,12 +20,12 @@
&.orient-right {
inset-inline: 0 auto;
translate: 0 -100%;
translate: var(--orient-offset, 0px) -100%;
}
&.orient-left {
inset-inline: auto 0;
translate: 0 -100%;
translate: var(--orient-offset, 0px) -100%;
}
}
@@ -1,4 +1,5 @@
class Account::ExportsController < ApplicationController
before_action :ensure_admin_or_owner
before_action :ensure_export_limit_not_exceeded, only: :create
before_action :set_export, only: :show
@@ -13,8 +14,12 @@ class Account::ExportsController < ApplicationController
end
private
def ensure_admin_or_owner
head :forbidden unless Current.user.admin? || Current.user.owner?
end
def ensure_export_limit_not_exceeded
head :too_many_requests if Current.user.exports.current.count >= CURRENT_EXPORT_LIMIT
head :too_many_requests if Current.account.exports.current.count >= CURRENT_EXPORT_LIMIT
end
def set_export
@@ -0,0 +1,44 @@
class Account::ImportsController < ApplicationController
layout "public"
disallow_account_scope only: %i[ new create ]
allow_unauthorized_access only: :show
before_action :set_import, only: %i[ show ]
before_action :ensure_accessed_by_owner, only: %i[ show ]
def new
end
def create
signup = Signup.new(identity: Current.identity, full_name: "Import", skip_account_seeding: true)
if signup.complete
start_import(signup.account)
else
render :new, alert: "Couldn't create account."
end
end
def show
end
private
def set_import
@import = Current.account.imports.find(params[:id])
end
def ensure_accessed_by_owner
head :forbidden unless @import.identity == Current.identity
end
def start_import(account)
import = nil
Current.set(account: account) do
import = account.imports.create!(identity: Current.identity, file: params[:file])
import.process_later
end
redirect_to account_import_path(import, script_name: account.slug)
end
end
+10 -2
View File
@@ -9,14 +9,22 @@ class Cards::PinsController < ApplicationController
@pin = @card.pin_by Current.user
broadcast_add_pin_to_tray
render_pin_button_replacement
respond_to do |format|
format.turbo_stream { render_pin_button_replacement }
format.json { head :no_content }
end
end
def destroy
@pin = @card.unpin_by Current.user
broadcast_remove_pin_from_tray
render_pin_button_replacement
respond_to do |format|
format.turbo_stream { render_pin_button_replacement }
format.json { head :no_content }
end
end
private
+10 -1
View File
@@ -1,6 +1,15 @@
class My::PinsController < ApplicationController
def index
@pins = Current.user.pins.includes(:card).ordered.limit(20)
@pins = user_pins
fresh_when etag: [ @pins, @pins.collect(&:card) ]
end
private
def user_pins
Current.user.pins.includes(:card).ordered.limit(pins_limit)
end
def pins_limit
request.format.json? ? 100 : 20
end
end
@@ -2,7 +2,7 @@ class Prompts::Boards::UsersController < ApplicationController
include BoardScoped
def index
@users = @board.users.alphabetically
@users = @board.users.active.alphabetically
if stale? etag: @users
render layout: false
@@ -46,7 +46,7 @@ class Sessions::MagicLinksController < ApplicationController
respond_to do |format|
format.html { redirect_to after_sign_in_url(magic_link) }
format.json { render json: { session_token: session_token } }
format.json { render json: { session_token: session_token, requires_signup_completion: requires_signup_completion?(magic_link) } }
end
end
@@ -68,7 +68,7 @@ class Sessions::MagicLinksController < ApplicationController
end
def after_sign_in_url(magic_link)
if magic_link.for_sign_up?
if requires_signup_completion?(magic_link)
new_signup_completion_path
else
after_authentication_url
@@ -82,4 +82,8 @@ class Sessions::MagicLinksController < ApplicationController
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
end
end
def requires_signup_completion?(magic_link)
magic_link.for_sign_up?
end
end
+5 -1
View File
@@ -20,7 +20,11 @@ class SessionsController < ApplicationController
def destroy
terminate_session
redirect_to_logout_url
respond_to do |format|
format.html { redirect_to_logout_url }
format.json { head :no_content }
end
end
private
@@ -11,10 +11,9 @@ class Signups::CompletionsController < ApplicationController
@signup = Signup.new(signup_params)
if @signup.complete
flash[:welcome_letter] = true
redirect_to landing_url(script_name: @signup.account.slug)
welcome_to_account
else
render :new, status: :unprocessable_entity
invalid_signup
end
end
@@ -22,4 +21,22 @@ class Signups::CompletionsController < ApplicationController
def signup_params
params.expect(signup: %i[ full_name ]).with_defaults(identity: Current.identity)
end
def welcome_to_account
respond_to do |format|
format.html do
flash[:welcome_letter] = true
redirect_to landing_url(script_name: @signup.account.slug)
end
format.json { render json: { account_id: @signup.account.id }, status: :created }
end
end
def invalid_signup
respond_to do |format|
format.html { render :new, status: :unprocessable_entity }
format.json { render json: { errors: @signup.errors.full_messages }, status: :unprocessable_entity }
end
end
end
@@ -0,0 +1,33 @@
class Users::DataExportsController < ApplicationController
before_action :set_user
before_action :ensure_current_user
before_action :ensure_export_limit_not_exceeded, only: :create
before_action :set_export, only: :show
CURRENT_EXPORT_LIMIT = 10
def show
end
def create
@user.data_exports.create!(account: Current.account).build_later
redirect_to @user, notice: "Export started. You'll receive an email when it's ready."
end
private
def set_user
@user = Current.account.users.find(params[:user_id])
end
def ensure_current_user
head :forbidden unless @user == Current.user
end
def ensure_export_limit_not_exceeded
head :too_many_requests if @user.data_exports.current.count >= CURRENT_EXPORT_LIMIT
end
def set_export
@export = @user.data_exports.completed.find_by(id: params[:id])
end
end
+1 -1
View File
@@ -11,7 +11,7 @@ module ApplicationHelper
end
def back_link_to(label, url, action, **options)
link_to url, class: "btn btn--back", data: { controller: "hotkey", action: action }, **options do
link_to url, class: "btn btn--back btn--circle-mobile", data: { controller: "hotkey", action: action }, **options do
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
+1 -1
View File
@@ -4,7 +4,7 @@ module BoardsHelper
end
def link_to_edit_board(board)
link_to edit_board_path(board), class: "btn",
link_to edit_board_path(board), class: "btn btn--circle-mobile",
data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Board settings" } do
icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader")
end
+1 -1
View File
@@ -24,7 +24,7 @@ module WebhooksHelper
def link_to_webhooks(board, &)
link_to board_webhooks_path(board_id: board),
class: [ "btn", { "btn--reversed": board.webhooks.any? } ],
class: [ "btn btn--circle-mobile", { "btn--reversed": board.webhooks.any? } ],
data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Webhooks" } do
icon_tag("world") + tag.span("Webhooks", class: "for-screen-reader")
end
@@ -15,8 +15,8 @@ export default class extends Controller {
async connect() {
this.mediaQuery = window.matchMedia(this.desktopBreakpointValue)
this.handleDesktop = this.#handleDesktop.bind(this)
this.mediaQuery.addEventListener("change", this.handleDesktop)
this.handlePlatform = this.#handlePlatform.bind(this)
this.mediaQuery.addEventListener("change", this.handlePlatform)
await this.#restoreColumnsDisablingTransitions()
this.#setupIntersectionObserver()
@@ -27,7 +27,7 @@ export default class extends Controller {
this._intersectionObserver.disconnect()
this._intersectionObserver = null
}
this.mediaQuery.removeEventListener("change", this.handleDesktop)
this.mediaQuery.removeEventListener("change", this.handlePlatform)
}
toggle({ target }) {
@@ -56,7 +56,7 @@ export default class extends Controller {
async #restoreColumnsDisablingTransitions() {
this.#disableTransitions()
this.#restoreColumns()
this.#handleDesktop()
this.#handlePlatform()
await nextFrame()
this.#enableTransitions()
@@ -163,7 +163,7 @@ export default class extends Controller {
return this.mediaQuery?.matches
}
#handleDesktop() {
#handlePlatform() {
this.#isDesktop ? this.#handleDesktopMode() : this.#handleMobileMode()
}
@@ -181,6 +181,7 @@ export default class extends Controller {
this.#collapseAllExcept(expandedColumn)
} else {
this.#collapseAllExcept(this.maybeColumnTarget)
this.#expand(this.maybeColumnTarget, false)
}
}
@@ -26,7 +26,7 @@ export default class extends Controller {
this.dialogTarget.showModal()
} else {
this.dialogTarget.show()
orient(this.dialogTarget)
orient({ target: this.dialogTarget, anchor: this.element })
}
this.loadLazyFrames()
@@ -46,7 +46,7 @@ export default class extends Controller {
this.dialogTarget.close()
this.dialogTarget.setAttribute("aria-hidden", "true")
this.dialogTarget.blur()
orient(this.dialogTarget, false)
orient({ target: this.dialogTarget, reset: true })
this.dispatch("close")
}
@@ -0,0 +1,16 @@
import { Controller } from "@hotwired/stimulus"
import { isNative } from "helpers/platform_helpers"
export default class extends Controller {
static get shouldLoad() {
return isNative()
}
static values = { autoExpandSelector: String }
connect() {
if (this.hasAutoExpandSelectorValue && this.element.querySelector(this.autoExpandSelectorValue)) {
this.element.open = true
}
}
}
@@ -30,7 +30,7 @@ export default class extends Controller {
reset() {
this.zoomedImageTarget.src = ""
this.captionTarget.innerText = ""
this.captionTarget.innerHTML = "&nbsp;"
this.dispatch('closed')
}
@@ -15,11 +15,11 @@ export default class extends Controller {
}
mouseEnter(event) {
orient(this.#tooltipElement)
orient({ target: this.#tooltipElement, anchor: this.element })
}
mouseOut(event) {
orient(this.#tooltipElement, false)
orient({ target: this.#tooltipElement, reset: true })
}
get #tooltipElement() {
@@ -0,0 +1,16 @@
import { Controller } from "@hotwired/stimulus"
import { isTouchDevice } from "helpers/platform_helpers"
export default class extends Controller {
static get shouldLoad() {
return isTouchDevice()
}
static values = { placeholder: String }
connect() {
if (this.hasPlaceholderValue) {
this.element.placeholder = this.placeholderValue
}
}
}
@@ -1,14 +1,31 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "image", "input" ]
static targets = [ "image", "input", "fileName", "placeholder" ]
previewImage() {
const file = this.inputTarget.files[0]
if (file) {
this.imageTarget.src = URL.createObjectURL(file)
if (this.#file) {
this.imageTarget.src = URL.createObjectURL(this.#file)
this.imageTarget.onload = () => URL.revokeObjectURL(this.imageTarget.src)
}
}
previewFileName() {
this.#file ? this.#showFileName() : this.#showPlaceholder()
}
#showFileName() {
this.fileNameTarget.innerHTML = this.#file.name
this.fileNameTarget.removeAttribute("hidden")
this.placeholderTarget.setAttribute("hidden", true)
}
#showPlaceholder() {
this.placeholderTarget.removeAttribute("hidden")
this.fileNameTarget.setAttribute("hidden", true)
}
get #file() {
return this.inputTarget.files[0]
}
}
+29 -13
View File
@@ -1,24 +1,40 @@
const EDGE_THRESHOLD = 16
export function orient(el, orient = true) {
el.classList.remove("orient-left", "orient-right")
export function orient({ target, anchor = null, reset = false }) {
target.classList.remove("orient-left", "orient-right")
target.style.removeProperty("--orient-offset")
if (!orient) return
if (reset) return
const rightSpace = spaceOnRight(el)
const leftSpace = spaceOnLeft(el)
const targetGeometry = geometry(target)
const anchorGeometry = geometry(anchor)
const shouldOrientLeft = targetGeometry.spaceOnRight < EDGE_THRESHOLD && targetGeometry.spaceOnRight < targetGeometry.spaceOnLeft
const shouldOrientRight = targetGeometry.spaceOnLeft < EDGE_THRESHOLD && targetGeometry.spaceOnLeft < targetGeometry.spaceOnRight
if (rightSpace < EDGE_THRESHOLD && rightSpace < leftSpace) {
el.classList.add("orient-left")
} else if (leftSpace < EDGE_THRESHOLD && leftSpace < rightSpace) {
el.classList.add("orient-right")
if (shouldOrientLeft) {
orientLeft({ el: target, targetGeometry, anchorGeometry })
} else if (shouldOrientRight) {
orientRight({ el: target, targetGeometry, anchorGeometry })
}
}
function spaceOnLeft(el) {
return el.getBoundingClientRect().left
function orientLeft({ el, targetGeometry, anchorGeometry }) {
const offset = Math.min(0, anchorGeometry.spaceOnLeft + anchorGeometry.width - targetGeometry.width) * -1
el.classList.add("orient-left")
el.style.setProperty("--orient-offset", `${offset}px`)
}
function spaceOnRight(el) {
return window.innerWidth - el.getBoundingClientRect().right
function orientRight({ el, targetGeometry, anchorGeometry }) {
const offset = Math.max(0, anchorGeometry.spaceOnLeft + targetGeometry.width - window.innerWidth) * -1
el.classList.add("orient-right")
el.style.setProperty("--orient-offset", `${offset}px`)
}
function geometry(el) {
const rect = el.getBoundingClientRect()
return {
spaceOnLeft: rect.left,
spaceOnRight: window.innerWidth - rect.right,
width: rect.width
}
}
@@ -13,3 +13,7 @@ export function isAndroid() {
export function isMobile() {
return isIos() || isAndroid()
}
export function isNative() {
return /Hotwire Native/.test(navigator.userAgent)
}
+20
View File
@@ -0,0 +1,20 @@
class Account::DataImportJob < ApplicationJob
include ActiveJob::Continuable
queue_as :backend
discard_on Account::DataTransfer::RecordSet::IntegrityError, ZipFile::InvalidFileError
def perform(import)
step :check do |step|
import.check \
start: step.cursor,
callback: ->(record_set:, file:) { step.set!([ record_set.model.name, file ]) }
end
step :process do |step|
import.process \
start: step.cursor,
callback: ->(record_set:, files:) { step.set!([ record_set.model.name, files.last ]) }
end
end
end
@@ -1,4 +1,4 @@
class ExportAccountDataJob < ApplicationJob
class DataExportJob < ApplicationJob
queue_as :backend
discard_on ActiveJob::DeserializationError
+11
View File
@@ -1,8 +1,19 @@
class ExportMailer < ApplicationMailer
helper_method :export_download_url
def completed(export)
@export = export
@user = export.user
mail to: @user.identity.email_address, subject: "Your Fizzy data export is ready for download"
end
private
def export_download_url(export)
if export.is_a?(User::DataExport)
user_data_export_url(export.user, export)
else
account_export_url(export)
end
end
end
+10
View File
@@ -0,0 +1,10 @@
class ImportMailer < ApplicationMailer
def completed(identity, account)
@account = account
mail to: identity.email_address, subject: "Your Fizzy account import is done"
end
def failed(identity)
mail to: identity.email_address, subject: "Your Fizzy account import failed"
end
end
+13
View File
@@ -8,7 +8,12 @@ class Account < ApplicationRecord
has_many :webhooks, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :columns, dependent: :destroy
has_many :entropies, dependent: :destroy
has_many :exports, class_name: "Account::Export", dependent: :destroy
has_many :imports, class_name: "Account::Import", dependent: :destroy
scope :importing, -> { left_joins(:imports).where(account_imports: { status: %i[pending processing failed] }) }
scope :active, -> { where.missing(:cancellation).and(where.not(id: importing)) }
before_create :assign_external_account_id
after_create :create_join_code
@@ -36,6 +41,14 @@ class Account < ApplicationRecord
users.find_by!(role: :system)
end
def active?
!cancelled? && !importing?
end
def importing?
imports.where(status: %i[pending processing failed]).exists?
end
private
def assign_external_account_id
self.external_account_id ||= ExternalIdSequence.next
-6
View File
@@ -4,8 +4,6 @@ module Account::Cancellable
included do
has_one :cancellation, dependent: :destroy
scope :active, -> { where.missing(:cancellation) }
define_callbacks :cancel
define_callbacks :reactivate
end
@@ -32,10 +30,6 @@ module Account::Cancellable
end
end
def active?
!cancelled?
end
def cancelled?
cancellation.present?
end
@@ -0,0 +1,58 @@
class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet
ACCOUNT_ATTRIBUTES = %w[
join_code
name
]
JOIN_CODE_ATTRIBUTES = %w[
code
usage_count
usage_limit
]
def initialize(account)
super(account: account, model: Account)
end
private
def records
[ account ]
end
def export_record(account)
zip.add_file "data/account.json", account.as_json.merge(join_code: account.join_code.as_json).to_json
end
def files
[ "data/account.json" ]
end
def import_batch(files)
account_data = load(files.first)
join_code_data = account_data.delete("join_code")
account.update!(name: account_data.fetch("name"))
account.join_code.update!(join_code_data.slice("usage_count", "usage_limit"))
account.join_code.update(code: join_code_data.fetch("code"))
end
def check_record(file_path)
data = load(file_path)
unless (ACCOUNT_ATTRIBUTES - data.keys).empty?
raise IntegrityError, "Account record missing required fields"
end
unless data.key?("join_code")
raise IntegrityError, "Account record missing 'join_code' field"
end
unless data["join_code"].is_a?(Hash)
raise IntegrityError, "'join_code' field must be a JSON object"
end
unless (JOIN_CODE_ATTRIBUTES - data["join_code"].keys).empty?
raise IntegrityError, "'join_code' field missing required keys"
end
end
end
@@ -0,0 +1,95 @@
class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer::RecordSet
ATTRIBUTES = %w[
account_id
body
created_at
id
name
record_id
record_type
updated_at
].freeze
def initialize(account)
super(account: account, model: ActionText::RichText)
end
private
def records
ActionText::RichText.where(account: account)
end
def export_record(rich_text)
data = rich_text.as_json.merge("body" => convert_sgids_to_gids(rich_text.body))
zip.add_file "data/action_text_rich_texts/#{rich_text.id}.json", data.to_json
end
def files
zip.glob("data/action_text_rich_texts/*.json")
end
def import_batch(files)
batch_data = files.map do |file|
data = load(file)
data["body"] = convert_gids_to_sgids(data["body"])
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
end
ActionText::RichText.insert_all!(batch_data)
end
def check_record(file_path)
data = load(file_path)
expected_id = File.basename(file_path, ".json")
unless data["id"].to_s == expected_id
raise IntegrityError, "ActionTextRichText record ID mismatch: expected #{expected_id}, got #{data['id']}"
end
missing = ATTRIBUTES - data.keys
if missing.any?
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
end
check_associations_dont_exist(data)
end
def convert_sgids_to_gids(content)
return nil if content.blank?
content.send(:attachment_nodes).each do |node|
sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME)
record = begin
sgid&.find
rescue ActiveRecord::RecordNotFound
nil
end
if record&.account_id == account.id
node["gid"] = record.to_global_id.to_s
node.remove_attribute("sgid")
end
end
content.fragment.source.to_html
end
def convert_gids_to_sgids(html)
return html if html.blank?
fragment = Nokogiri::HTML.fragment(html)
fragment.css("action-text-attachment[gid]").each do |node|
gid = GlobalID.parse(node["gid"])
if gid
record = gid.find
node["sgid"] = record.attachable_sgid
node.remove_attribute("gid")
end
end
fragment.to_html
end
end
@@ -0,0 +1,22 @@
class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer::RecordSet
def initialize(account)
super(
account: account,
model: ActiveStorage::Blob,
attributes: ActiveStorage::Blob.column_names - %w[service_name]
)
end
private
def import_batch(files)
batch_data = files.map do |file|
data = load(file)
data.slice(*attributes).merge(
"account_id" => account.id,
"service_name" => ActiveStorage::Blob.service.name
)
end
model.insert_all!(batch_data)
end
end
@@ -0,0 +1,43 @@
class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSet
def initialize(account)
super(account: account, model: ActiveStorage::Blob)
end
private
def records
ActiveStorage::Blob.where(account: account)
end
def export_record(blob)
if blob.service.exist?(blob.key)
zip.add_file("storage/#{blob.key}", compress: false) do |out|
blob.download { |chunk| out.write(chunk) }
end
end
end
def files
zip.glob("storage/*")
end
def import_batch(files)
files.each do |file|
key = File.basename(file)
blob = ActiveStorage::Blob.find_by(key: key, account: account)
next unless blob
zip.read(file) do |stream|
blob.upload(stream)
end
end
end
def check_record(file_path)
key = File.basename(file_path)
unless zip.exists?("data/active_storage_blobs/#{key}.json") || ActiveStorage::Blob.exists?(key: key, account: account)
# File exists without corresponding blob record - could be orphaned or blob not yet imported
# We allow this since blob metadata is imported before files
end
end
end
@@ -0,0 +1,32 @@
class Account::DataTransfer::EntropyRecordSet < Account::DataTransfer::RecordSet
def initialize(account)
super(account: account, model: Entropy)
end
private
def import_batch(files)
batch_data = files.map do |file|
data = load(file)
data.slice(*attributes).merge("account_id" => account.id)
end
container_keys = batch_data.map { |d| [ d["container_type"], d["container_id"] ] }
existing_containers = Entropy
.where(account_id: account.id)
.where(container_type: container_keys.map(&:first), container_id: container_keys.map(&:last))
.pluck(:container_type, :container_id)
.to_set
to_update, to_insert = batch_data.partition do |data|
existing_containers.include?([ data["container_type"], data["container_id"] ])
end
to_update.each do |data|
Entropy
.find_by(account_id: account.id, container_type: data["container_type"], container_id: data["container_id"])
.update!(data.slice("auto_postpone_period"))
end
Entropy.insert_all!(to_insert) if to_insert.any?
end
end
@@ -0,0 +1,50 @@
class Account::DataTransfer::Manifest
attr_reader :account
def initialize(account)
@account = account
end
def each_record_set(start: nil)
raise ArgumentError, "No block given" unless block_given?
started = start.nil?
record_class, last_id = start if start
record_sets.each do |record_set|
if started
yield record_set
elsif record_set.model.name == record_class
started = true
yield record_set, last_id
end
end
end
private
def record_sets
[
Account::DataTransfer::AccountRecordSet.new(account),
Account::DataTransfer::UserRecordSet.new(account),
*build_record_sets(::User::Settings, ::Tag, ::Board, ::Column),
Account::DataTransfer::EntropyRecordSet.new(account),
*build_record_sets(
::Board::Publication, ::Webhook, ::Access, ::Card, ::Comment, ::Step,
::Assignment, ::Tagging, ::Closure, ::Card::Goldness, ::Card::NotNow,
::Card::ActivitySpike, ::Watch, ::Pin, ::Reaction, ::Mention,
::Filter, ::Webhook::DelinquencyTracker, ::Event,
::Notification, ::Notification::Bundle, ::Webhook::Delivery
),
Account::DataTransfer::ActiveStorageBlobRecordSet.new(account),
*build_record_sets(::ActiveStorage::Attachment),
Account::DataTransfer::ActionTextRichTextRecordSet.new(account),
Account::DataTransfer::BlobFileRecordSet.new(account)
]
end
def build_record_sets(*models)
models.map do |model|
Account::DataTransfer::RecordSet.new(account: account, model: model)
end
end
end
@@ -0,0 +1,144 @@
class Account::DataTransfer::RecordSet
class IntegrityError < StandardError; end
IMPORT_BATCH_SIZE = 100
attr_reader :account, :model, :attributes
def initialize(account:, model:, attributes: nil)
@account = account
@model = model
@attributes = (attributes || model.column_names).map(&:to_s)
end
def export(to:, start: nil)
with_zip(to) do
block = lambda do |record|
export_record(record)
end
records.respond_to?(:find_each) ? records.find_each(&block) : records.each(&block)
end
end
def import(from:, start: nil, callback: nil)
with_zip(from) do
file_list = files
file_list = skip_to(file_list, start) if start
file_list.each_slice(IMPORT_BATCH_SIZE) do |file_batch|
import_batch(file_batch)
callback&.call(record_set: self, files: file_batch)
end
end
end
def check(from:, start: nil, callback: nil)
with_zip(from) do
file_list = files
file_list = skip_to(file_list, start) if start
file_list.each do |file_path|
check_record(file_path)
callback&.call(record_set: self, file: file_path)
end
end
end
private
attr_reader :zip
def with_zip(zip)
old_zip = @zip
@zip = zip
yield
ensure
@zip = old_zip
end
def records
model.where(account_id: account.id)
end
def export_record(record)
zip.add_file "data/#{model_dir}/#{record.id}.json", record.to_json
end
def files
zip.glob("data/#{model_dir}/*.json")
end
def import_batch(files)
batch_data = files.map do |file|
data = load(file)
data.slice(*attributes).merge("account_id" => account.id).tap do |record_data|
record_data["updated_at"] = Time.current if record_data.key?("updated_at")
end
end
model.insert_all!(batch_data)
end
def check_record(file_path)
data = load(file_path)
expected_id = File.basename(file_path, ".json")
unless data["id"].to_s == expected_id
raise IntegrityError, "#{model} record ID mismatch: expected #{expected_id}, got #{data['id']}"
end
missing = attributes - data.keys
if missing.any?
raise IntegrityError, "#{file_path} is missing required fields: #{missing.join(', ')}"
end
if model.exists?(id: data["id"])
raise IntegrityError, "#{model} record with ID #{data['id']} already exists"
end
check_associations_dont_exist(data)
end
def check_associations_dont_exist(data)
model.reflect_on_all_associations(:belongs_to).each do |association|
foreign_key = association.foreign_key.to_s
if associated_id = data[foreign_key]
check_association_doesnt_exist(data, association, associated_id)
end
end
end
def check_association_doesnt_exist(data, association, associated_id)
if association.polymorphic?
type_column = association.foreign_type.to_s
associated_class = data[type_column].constantize
else
associated_class = association.klass
end
if associated_class.exists?(id: associated_id)
raise IntegrityError, "#{model} record references existing #{association.name} (#{associated_class}) with ID #{associated_id}"
end
end
def skip_to(file_list, last_id)
index = file_list.index(last_id)
if index
file_list[(index + 1)..]
else
file_list
end
end
def load(file_path)
JSON.parse(zip.read(file_path))
rescue ArgumentError => e
raise IntegrityError, e.message
end
def model_dir
model.table_name
end
end
@@ -0,0 +1,61 @@
class Account::DataTransfer::UserRecordSet < Account::DataTransfer::RecordSet
ATTRIBUTES = %w[
id
email_address
name
role
active
verified_at
created_at
updated_at
]
def initialize(account)
super(account: account, model: User)
end
private
def records
User.where(account: account)
end
def export_record(user)
zip.add_file "data/users/#{user.id}.json", user.as_json.merge(email_address: user.identity&.email_address).to_json
end
def files
zip.glob("data/users/*.json")
end
def import_batch(files)
batch_data = files.map do |file|
user_data = load(file)
email_address = user_data.delete("email_address")
identity = Identity.find_or_create_by!(email_address: email_address) if email_address.present?
user_data.slice(*ATTRIBUTES).merge(
"account_id" => account.id,
"identity_id" => identity&.id
)
end
conflicting_identity_ids = batch_data.pluck("identity_id").compact
account.users.where(identity_id: conflicting_identity_ids).destroy_all
User.insert_all!(batch_data)
end
def check_record(file_path)
data = load(file_path)
expected_id = File.basename(file_path, ".json")
unless data["id"].to_s == expected_id
raise IntegrityError, "User record ID mismatch: expected #{expected_id}, got #{data['id']}"
end
unless (ATTRIBUTES - data.keys).empty?
raise IntegrityError, "#{file_path} is missing required fields"
end
end
end
+6 -71
View File
@@ -1,77 +1,12 @@
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
def accessible_to?(accessor)
accessor == user
end
class Account::Export < Export
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
def filename
"fizzy-account-#{account_id}-export-#{id}.zip"
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
def populate_zip(zip)
Account::DataTransfer::Manifest.new(account).each_record_set do |record_set|
record_set.export(to: zip)
end
end
end
+81
View File
@@ -0,0 +1,81 @@
class Account::Import < ApplicationRecord
broadcasts_refreshes
belongs_to :account
belongs_to :identity
has_one_attached :file
enum :status, %w[ pending processing completed failed ].index_by(&:itself), default: :pending
scope :expired, -> { where(completed_at: ...24.hours.ago).or(where(status: :failed, created_at: ...7.days.ago)) }
def self.cleanup
expired.each(&:cleanup)
end
def process_later
Account::DataImportJob.perform_later(self)
end
def check(start: nil, callback: nil)
processing!
ZipFile.read_from(file.blob) do |zip|
Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id|
record_set.check(from: zip, start: last_id, callback: callback)
end
end
rescue => e
mark_as_failed
raise e
end
def process(start: nil, callback: nil)
processing!
ZipFile.read_from(file.blob) do |zip|
Account::DataTransfer::Manifest.new(account).each_record_set(start: start) do |record_set, last_id|
record_set.import(from: zip, start: last_id, callback: callback)
end
end
add_importer_to_all_access_boards
reconcile_account_storage
mark_completed
rescue => e
mark_as_failed
raise e
end
def cleanup
destroy
account.destroy if failed?
end
private
def mark_completed
update!(status: :completed, completed_at: Time.current)
ImportMailer.completed(identity, account).deliver_later
end
def mark_as_failed
failed!
ImportMailer.failed(identity).deliver_later
end
def add_importer_to_all_access_boards
importer = account.users.find_by!(identity: identity)
account.boards.all_access.find_each do |board|
board.accesses.grant_to(importer)
end
end
def reconcile_account_storage
account.boards.each(&:reconcile_storage)
account.reconcile_storage
account.materialize_storage
end
end
+1
View File
@@ -58,6 +58,7 @@ class Card < ApplicationRecord
transaction do
card.update!(board: new_board)
card.events.update_all(board_id: new_board.id)
Event.where(eventable: card.comments).update_all(board_id: new_board.id)
end
end
+1 -1
View File
@@ -67,7 +67,7 @@ module Card::Exportable
end
def collect_attachments
attachments.to_a + comments.flat_map { |c| c.attachments.to_a }
(attachments.to_a + comments.flat_map { |c| c.attachments.to_a }).uniq(&:blob_id)
end
def export_status
+1 -1
View File
@@ -2,5 +2,5 @@ class Entropy < ApplicationRecord
belongs_to :account, default: -> { container.account }
belongs_to :container, polymorphic: true
after_commit -> { container.cards.touch_all }
after_commit -> { container.cards.touch_all if container }
end
+62
View File
@@ -0,0 +1,62 @@
class 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
DataExportJob.perform_later(self)
end
def build
processing!
with_context do
ZipFile.create_for(file, filename: filename) do |zip|
populate_zip(zip)
end
mark_completed
ExportMailer.completed(self).deliver_later
end
rescue => e
update!(status: :failed)
raise e
end
def mark_completed
update!(status: :completed, completed_at: Time.current)
end
def accessible_to?(accessor)
accessor == user
end
private
def filename
"fizzy-export-#{id}.zip"
end
def with_context
Current.set(account: account) do
old_url_options = ActiveStorage::Current.url_options
ActiveStorage::Current.url_options = Rails.application.routes.default_url_options
yield
ensure
ActiveStorage::Current.url_options = old_url_options
end
end
def populate_zip(zip)
raise NotImplementedError, "Subclasses must implement populate_zip"
end
end
+2 -2
View File
@@ -3,7 +3,7 @@ class Signup
include ActiveModel::Attributes
include ActiveModel::Validations
attr_accessor :full_name, :email_address, :identity
attr_accessor :full_name, :email_address, :identity, :skip_account_seeding
attr_reader :account, :user
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, on: :identity_creation
@@ -65,7 +65,7 @@ class Signup
}
)
@user = @account.users.find_by!(role: :owner)
@account.setup_customer_template
@account.setup_customer_template unless skip_account_seeding
end
def generate_account_name
+1 -1
View File
@@ -14,7 +14,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
has_many :data_exports, class_name: "User::DataExport", dependent: :destroy
def deactivate
transaction do
+33
View File
@@ -0,0 +1,33 @@
class User::DataExport < Export
private
def filename
"fizzy-user-data-export-#{id}.zip"
end
def populate_zip(zip)
exportable_cards.find_each do |card|
add_card_to_zip(zip, card)
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.add_file("#{card.number}.json", card.export_json)
card.export_attachments.each do |attachment|
zip.add_file(attachment[:path], compress: false) 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
+99
View File
@@ -0,0 +1,99 @@
class ZipFile
class InvalidFileError < StandardError; end
class << self
def create_for(attachment, filename:)
raise ArgumentError, "No block given" unless block_given?
reflection = attachment.record.class.reflect_on_attachment(attachment.name)
service_name = reflection.options[:service_name] || ActiveStorage::Blob.service.name
service = ActiveStorage::Blob.services.fetch(service_name)
if s3_service?(service)
create_for_s3(attachment, filename: filename, service: service) { |zip| yield zip }
else
create_for_disk(attachment, filename: filename) { |zip| yield zip }
end
end
def read_from(blob)
raise ArgumentError, "No block given" unless block_given?
if s3_service?(blob.service)
read_from_s3(blob) { |zip| yield zip }
else
read_from_disk(blob) { |zip| yield zip }
end
end
private
def s3_service?(service)
# The S3 service doesn't get loaded in development unless it's used
defined?(ActiveStorage::Service::S3Service) && service.is_a?(ActiveStorage::Service::S3Service)
end
def create_for_s3(attachment, filename:, service:)
blob = ActiveStorage::Blob.create_before_direct_upload!(
filename: filename,
content_type: "application/zip",
byte_size: 0,
checksum: "pending"
)
writer = Writer.new
# Use S3's upload_stream directly for write-based streaming.
# ActiveStorage's upload method expects a read-based IO, but ZipKit
# needs a write-based stream. The TransferManager's upload_stream
# yields a writable IO that we can stream directly to.
service.send(:upload_stream,
key: blob.key,
content_type: "application/zip",
part_size: 100.megabytes
) do |write_stream|
write_stream.binmode
writer.stream_to(write_stream)
yield writer
writer.close
end
blob.update!(byte_size: writer.byte_size, checksum: writer.checksum)
attachment.attach(blob)
rescue Aws::S3::MultipartUploadError => e
if e.errors.any?
raise e.errors.first
else
raise e
end
end
def create_for_disk(attachment, filename:)
tempfile = Tempfile.new([ "export", ".zip" ])
tempfile.binmode
writer = Writer.new(tempfile)
yield writer
writer.close
tempfile.rewind
attachment.attach(io: tempfile, filename: filename, content_type: "application/zip")
ensure
tempfile&.close
tempfile&.unlink
end
def read_from_s3(blob)
url = blob.url(expires_in: 6.hour)
remote_io = RemoteIO.new(url)
reader = Reader.new(remote_io)
yield reader
end
def read_from_disk(blob)
blob.open do |file|
reader = Reader.new(file)
yield reader
end
end
end
end
+28
View File
@@ -0,0 +1,28 @@
class ZipFile::Reader
def initialize(io)
@io = io
@reader = ZipKit::FileReader.read_zip_structure(io: io)
rescue ZipKit::FileReader::InvalidStructure => e
raise ZipFile::InvalidFileError, e.message
end
def read(file_path)
entry = @reader.find { |e| e.filename == file_path }
raise ArgumentError, "File not found in zip: #{file_path}" unless entry
raise ArgumentError, "Cannot read directory entry: #{file_path}" if entry.filename.end_with?("/")
if block_given?
yield ZipFile::Reader::IO.new(entry, @io)
else
entry.extractor_from(@io).extract
end
end
def glob(pattern)
@reader.map(&:filename).select { |name| File.fnmatch(pattern, name) }.sort
end
def exists?(file_path)
@reader.any? { |e| e.filename == file_path }
end
end
+34
View File
@@ -0,0 +1,34 @@
class ZipFile::Reader::IO
def initialize(entry, io)
@entry = entry
@io = io
@extractor = @entry.extractor_from(@io)
end
def read(length = nil, buffer = nil)
return nil if @extractor.eof?
data = @extractor.extract(length)
return nil if data.nil?
if buffer
buffer.replace(data)
buffer
else
data
end
end
def eof?
@extractor.eof?
end
def rewind
@extractor = @entry.extractor_from(@io)
0
end
def size
@entry.uncompressed_size
end
end
+44
View File
@@ -0,0 +1,44 @@
class ZipFile::RemoteIO < ZipKit::RemoteIO
protected
def request_range(range)
with_http do |http|
request = Net::HTTP::Get.new(@uri)
request.range = range
response = http.request(request)
case response.code
when "206", "200"
response.body
else
raise "Remote at #{@uri} replied with code #{response.code}"
end
end
end
def request_object_size
with_http do |http|
request = Net::HTTP::Get.new(@uri)
request.range = 0..0
response = http.request(request)
case response.code
when "206"
content_range_header_value = response["Content-Range"]
content_range_header_value.split("/").last.to_i
when "200"
response["Content-Length"].to_i
else
raise "Remote at #{@uri} replied with code #{response.code}"
end
end
end
private
def with_http
http = Net::HTTP.new(@uri.hostname, @uri.port)
http.use_ssl = @uri.scheme == "https"
# FIXME: Disable SSL verification for now to avoid issues with our self-signed certificates for PureStorage
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.start { yield http }
end
end
+54
View File
@@ -0,0 +1,54 @@
class ZipFile::Writer
attr_reader :byte_size
def initialize(io = nil)
@entries = []
@byte_size = 0
@output_io = io
@streamer = nil
@digest = Digest::MD5.new
end
def stream_to(io)
@output_io = io
end
def write(data)
@output_io.write(data)
@byte_size += data.bytesize
@digest.update(data)
data.bytesize
end
def add_file(path, content = nil, compress: true)
@entries << path
write_method = compress ? :write_deflated_file : :write_stored_file
if block_given?
streamer.public_send(write_method, path) { |sink| yield sink }
else
streamer.public_send(write_method, path) { |sink| sink.write(content) }
end
end
def glob(pattern)
@entries.select { |e| File.fnmatch(pattern, e) }.sort
end
def exists?(path)
@entries.include?(path)
end
def close
streamer.close
end
def checksum
Base64.strict_encode64(@digest.digest)
end
private
def streamer
@streamer ||= ZipKit::Streamer.new(@output_io)
end
end
+6 -8
View File
@@ -10,20 +10,18 @@
</div>
<% end %>
<div class="panel panel--wide shadow center flex flex-column gap">
<h2 class="txt-large margin-none font-weight-black"><%= @page_title %></h2>
<div class="panel panel--wide shadow center flex flex-column align-center gap">
<h2 class="txt-large font-weight-black margin-none"><%= @page_title %></h2>
<% if @export.present? %>
<p class="margin-none-block-start">Your export is ready. The download should start automatically.</p>
<div>Your export is ready. The download should start automatically.</div>
<%= link_to rails_blob_path(@export.file, disposition: "attachment"),
<%= link_to "Download your data", 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 %>
data: { turbo: false, controller: "auto-click" } %>
<% else %>
<p class="margin-none-block-start">That download link has expired. Youll need to <%= link_to "request a new export", account_settings_path, class: "txt-lnk" %>.</p>
<div>That download link has expired. Youll need to <%= link_to "request a new export", account_settings_path, class: "txt-link" %>.</div>
<% end %>
</div>
+24
View File
@@ -0,0 +1,24 @@
<% @page_title = "Import an account" %>
<div class="panel panel--centered flex flex-column gap">
<header>
<h1 class="txt-x-large font-weight-black txt-tight-lines margin-block-end-half">Import a Fizzy account</h1>
<div>Create an account using data from a Fizzy export. Upload the exported .zip file below.</div>
</header>
<%= form_with url: account_imports_path, class: "flex flex-column gap", data: { controller: "form upload-preview" }, multipart: true do |form| %>
<label class="btn input--upload">
<div data-upload-preview-target="placeholder">Choose a file…</div>
<div data-upload-preview-target="fileName" hidden></div>
<%= form.file_field :file, accept: ".zip", required: true, data: { action: "upload-preview#previewFileName", upload_preview_target: "input" } %>
</label>
<button type="submit" class="btn btn--link center" data-form-target="submit">
<span>Start Import →</span>
</button>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+23
View File
@@ -0,0 +1,23 @@
<% @page_title = "Import status" %>
<%= turbo_stream_from @import %>
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-block-end">Import status</h1>
<% case @import.status %>
<% when "pending", "processing" %>
<p class="txt-medium">Your import is in progress. This may take a while for large accounts.</p>
<% when "completed" %>
<p class="txt-medium txt-positive">Your import has completed successfully!</p>
<%= link_to "Go to your account", landing_url(script_name: @import.account.slug), class: "btn btn--link center txt-medium" %>
<% when "failed" %>
<p class="txt-medium txt-negative">Your import failed.</p>
<p class="txt-small">This may be due to corrupted export data or a conflict with existing data. Please try again with a fresh export.</p>
<%= link_to "Try again", new_account_import_path, class: "btn btn--plain center txt-medium" %>
<% end %>
</div>
<% content_for :footer do %>
<%= render "sessions/footer" %>
<% end %>
+7 -6
View File
@@ -1,7 +1,8 @@
<header>
<h2 class="divider txt-large">Auto close</h2>
<p class="margin-none-block-start">Fizzy doesnt let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. <em>This is the default, global setting — you can override it on each board.</em></p>
</header>
<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %>
<section class="settings__section">
<header>
<h2 class="divider">Auto close</h2>
<div>Fizzy doesnt let stale cards stick around forever. Cards automatically move to “Not Now” if there is no activity for a specific period of time. <em>This is the default, global setting — you can override it on each board.</em></div>
</header>
<%= render "entropy/auto_close", model: account.entropy, url: account_entropy_path, disabled: !Current.user.admin? %>
</section>
+18 -16
View File
@@ -1,19 +1,21 @@
<header class="margin-block-start-double">
<h2 class="divider txt-large">Export your data</h2>
<p class="margin-none-block">Download an archive of your Fizzy data.</p>
</header>
<section class="settings__section">
<header>
<h2 class="divider">Export account data</h2>
<div>Download a complete archive of all account data.</div>
</header>
<div data-controller="dialog" data-dialog-modal-value="true">
<button type="button" class="btn" data-action="dialog#open">Begin export...</button>
<div data-controller="dialog" data-dialog-modal-value="true" data-action="keydown.esc->dialog#close">
<button type="button" class="btn" data-action="dialog#open">Begin export...</button>
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog">
<h2 class="margin-none txt-large">Export your account data</h2>
<p class="margin-none-block-start">This will kick off a request to generate a ZIP archive of all the data in boards you have access to.</p>
<p>When the file is ready, well email you a link to download it. The link will expire after 24 hours.</p>
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog" style="--panel-size: 48ch;">
<h2 class="txt-large">Export all account data</h2>
<p>This will generate a ZIP archive of all data in this account including all boards, cards, users, and settings.</p>
<p>Well email you a link to download the file when its ready. The link will expire after 24 hours.</p>
<div class="flex gap justify-center">
<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
</div>
</dialog>
</div>
<div class="flex gap justify-center">
<%= button_to "Start export", account_exports_path, method: :post, class: "btn btn--link", form: { data: { action: "submit->dialog#close" } } %>
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
</div>
</dialog>
</div>
</section>
+9 -7
View File
@@ -1,10 +1,12 @@
<%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: "form" }, class: "flex gap-half" do |form| %>
<strong class="full-width"><%= form.text_field :name, required: true, class: "input input--transparent full-width txt-medium", placeholder: "Account name…", data: { action: "input->form#disableSubmitWhenInvalid" }, readonly: !Current.user.admin? %></strong>
<section class="settings__section">
<%= form_with model: account, url: account_settings_path, method: :put, scope: :account, data: { controller: "form" }, class: "flex gap-half" do |form| %>
<strong class="full-width"><%= form.text_field :name, required: true, class: "input input--transparent full-width txt-medium", placeholder: "Account name…", data: { action: "input->form#disableSubmitWhenInvalid" }, readonly: !Current.user.admin? %></strong>
<% if Current.user.admin? %>
<%= form.button class: "btn btn--circle btn--link txt-medium", data: { form_target: "submit" }, disabled: form.object do %>
<%= icon_tag "arrow-right" %>
<span class="for-screen-reader">Save changes</span>
<% if Current.user.admin? %>
<%= form.button class: "btn btn--circle btn--link txt-medium", data: { form_target: "submit" }, disabled: form.object do %>
<%= icon_tag "arrow-right" %>
<span class="for-screen-reader">Save changes</span>
<% end %>
<% end %>
<% end %>
<% end %>
</section>
+21 -19
View File
@@ -1,24 +1,26 @@
<header>
<h2 class="divider txt-medium margin-block-start">People on this account</h2>
</header>
<section class="settings__section">
<header>
<h2 class="divider">People on this account</h2>
</header>
<%= tag.div class: "flex flex-column gap settings__user-filter", data: {
controller: "filter navigable-list",
action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset",
navigable_list_focus_on_selection_value: true,
navigable_list_actionable_items_value: true
} do %>
<%= tag.div class: "flex flex-column gap settings__user-filter", data: {
controller: "filter navigable-list",
action: "keydown->navigable-list#navigate filter:changed->navigable-list#reset",
navigable_list_focus_on_selection_value: true,
navigable_list_actionable_items_value: true
} do %>
<div class="settings__user-filter">
<input placeholder="Filter…" class="input input--transparent full-width txt-small" type="search" autocorrect="off" autocomplete="off" data-1p-ignore="true" data-filter-target="input" data-action="input->filter#filter">
<div class="settings__user-filter">
<input placeholder="Filter…" class="input input--transparent full-width txt-small" type="search" autocorrect="off" autocomplete="off" data-1p-ignore="true" data-filter-target="input" data-action="input->filter#filter">
<ul class="settings__user-list margin-block-half" data-filter-target="list" role="listbox">
<%= render partial: "account/settings/user", collection: users %>
</ul>
</div>
<ul class="settings__user-list margin-block-half" data-filter-target="list" role="listbox">
<%= render partial: "account/settings/user", collection: users %>
</ul>
</div>
<%= link_to account_join_code_path, class: "btn btn--link center txt-small" do %>
<%= icon_tag "add" %>
<span>Invite people</span>
<%= link_to account_join_code_path, class: "btn btn--link center" do %>
<%= icon_tag "add" %>
<span>Invite people</span>
<% end %>
<% end %>
<% end %>
</section>
+3 -3
View File
@@ -9,7 +9,7 @@
</h1>
<% end %>
<section class="settings margin-block-start-half">
<div class="settings margin-block-start-half">
<div class="settings__panel settings__panel--users panel shadow center">
<%= render "account/settings/name", account: @account %>
<%= render "account/settings/users", users: @users %>
@@ -17,9 +17,9 @@
<div class="settings__panel settings__panel--entropy panel shadow center">
<%= render "account/settings/entropy", account: @account %>
<%= render "account/settings/export" %>
<%= render "account/settings/export" if Current.user.admin? || Current.user.owner? %>
<%= render "account/settings/cancellation" %>
</div>
</section>
</div>
<%= render "account/settings/subscription_panel" if Fizzy.saas? %>
+2 -2
View File
@@ -14,7 +14,7 @@
<% end %>
<section class="settings">
<div class="settings__panel settings__panel--users panel shadow center">
<div class="settings__panel settings__panel--users panel shadow">
<%= form_with model: @board, class: "display-contents", data: {
controller: "form boards-form",
boards_form_self_removal_prompt_message_value: "Are you sure you want to remove yourself from this board? You wont be able to get back in unless someone invites you.",
@@ -30,7 +30,7 @@
<% end %>
</div>
<div class="settings__panel panel shadow center">
<div class="settings__panel panel shadow">
<%= render "boards/edit/auto_close", board: @board %>
<%= render "boards/edit/publication", board: @board %>
<%= render "boards/edit/delete", board: @board if Current.user.can_administer_board?(@board) %>
+1
View File
@@ -1,4 +1,5 @@
<% @page_title = "Create a new board" %>
<% @body_class = "compact-on-touch" %>
<div class="panel panel--centered">
<%= bridged_form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>
+1 -1
View File
@@ -1,5 +1,5 @@
<div class="cards__new-column" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside" data-turbo-permanent>
<button class="btn btn--circle txt-x-small borderless" data-controller="tooltip" data-action="click->dialog#open:stop">
<button class="btn btn--circle btn--circle-mobile txt-x-small borderless" data-controller="tooltip" data-action="click->dialog#open:stop">
<%= icon_tag "add", class: "translucent" %>
<span class="for-screen-reader">Add a column</span>
</button>
+2 -2
View File
@@ -1,11 +1,11 @@
<% if card.golden? %>
<%= button_to card_goldness_path(card), method: :delete, class: "btn btn--reversed",
<%= button_to card_goldness_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Demote to normal" } do %>
<%= icon_tag "golden-ticket" %>
<span class="for-screen-reader">Demote to normal (shift+g)</span>
<% end %>
<% else %>
<%= button_to card_goldness_path(card), class: "btn",
<%= button_to card_goldness_path(card), class: "btn btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+g@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Promote to Golden Ticket" } do %>
<%= icon_tag "golden-ticket" %>
<span class="for-screen-reader">Promote to Golden Ticket (shift+g)</span>
+1 -1
View File
@@ -6,7 +6,7 @@
<% end %>
<% elsif !card.closed? %>
<%= form_with model: card, data: { controller: "form" } do |form| %>
<label class="card-perma__image-btn btn input--file" data-controller="tooltip" data-bridge--overflow-menu-target="item" data-bridge-title="Add background image">
<label class="card-perma__image-btn btn input--file btn--circle-mobile" data-controller="tooltip" data-bridge--overflow-menu-target="item" data-bridge-title="Add background image">
<%= icon_tag "picture-add" %>
<span class="for-screen-reader">Add background image</span>
+2 -2
View File
@@ -1,11 +1,11 @@
<div id="<%= dom_id(card, :pin_button) %>">
<% if card.pinned_by? Current.user %>
<%= button_to card_pin_path(card), method: :delete, class: "btn btn--reversed",
<%= button_to card_pin_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Un-pin this card" } do %>
<%= icon_tag "pinned" %> <span class="for-screen-reader">Un-pin this card (shift+p)</span>
<% end %>
<% else %>
<%= button_to card_pin_path(card), class: "btn",
<%= button_to card_pin_path(card), class: "btn btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Pin this card" } do %>
<%= icon_tag "unpinned" %> <span class="for-screen-reader">Pin this card (shift+p)</span>
<% end %>
+2 -1
View File
@@ -1,4 +1,5 @@
<%= turbo_stream.replace dom_id(@card, :card_container), partial: "cards/container", method: :morph, locals: { card: @card.reload } %>
<% container_partial = @card.drafted? ? "cards/drafts/container" : "cards/container" %>
<%= turbo_stream.replace dom_id(@card, :card_container), partial: container_partial, method: :morph, locals: { card: @card.reload } %>
<%= turbo_stream.update dom_id(@card, :edit) do %>
<%= render "cards/container/content_display", card: @card %>
@@ -1,12 +1,12 @@
<div id="<%= dom_id(card, :watch_button) %>">
<% if card.watched_by? Current.user %>
<%= button_to card_watch_path(card), method: :delete, class: "btn btn--reversed",
<%= button_to card_watch_path(card), method: :delete, class: "btn btn--reversed btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+n@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Stop watching" } do %>
<%= icon_tag "bell" %>
<span class="for-screen-reader">Stop watching (shift+n)</span>
<% end %>
<% else %>
<%= button_to card_watch_path(card), class: "btn",
<%= button_to card_watch_path(card), class: "btn btn--circle-mobile",
data: { controller: "tooltip hotkey", action: "keydown.shift+n@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Watch this" } do %>
<%= icon_tag "bell-off" %>
<span class="for-screen-reader">Watch this (shift+n)</span>
+1 -1
View File
@@ -1,5 +1,5 @@
<% cache event do %>
<%# Helper Dependency Updated: avatar_image_tag 2025-12-15 %>
<%# Template Dependency Updated: _layout.html.erb 2026-01-26 %>
<% if lookup_context.exists?("events/event/eventable/_#{event.action}") %>
<%= render "events/event/eventable/#{event.action}", event: event %>
<% else %>
@@ -1,4 +1,5 @@
<% cache [ day_timeline.events ] do %>
<%# Template Dependency Updated: _layout.html.erb 2026-01-26 %>
<% if day_timeline.has_activity? %>
<div class="events__columns">
<%= render "events/day_timeline/column", column: day_timeline.added_column %>
+3 -5
View File
@@ -7,8 +7,8 @@
related_element_target: "related",
related_element_group_value: card.id,
action: "mouseover->related-element#highlight mouseout->related-element#unhighlight" } do %>
<div class="card__header flex align-start gap full-width">
<h4 class="card__board flex-inline align-start">
<div class="card__header">
<h4 class="card__board">
<span class="card__id">
<span class="for-screen-reader">Card number</span>
<%= card.number %>
@@ -19,9 +19,7 @@
</h4>
<% unless event.action.in?(%w[ card_closed card_published card_reopened ]) %>
<span class="event__icon flex-item-justify-end txt-reversed">
<%= icon_tag event_action_icon(event) %>
</span>
<%= icon_tag event_action_icon(event), class: "event__icon" %>
<% end %>
</div>
<div class="event__content">
+30 -24
View File
@@ -1,29 +1,35 @@
<%= tag.aside \
class: class_names("filters margin-block-end", { "filters--expanded": user_filtering.expanded? }),
<%= tag.details class: "expandable-on-native",
data: {
controller: "toggle-enable toggle-class filter-settings dialog-manager",
toggle_class_toggle_class: "filters--expanded",
filter_settings_filters_set_class: "filters--has-filters-set",
filter_settings_no_filtering_url_value: no_filtering_url,
filter_settings_refresh_url_value: settings_refresh_path,
filter_settings_cards_url_value: cards_path,
turbo_permanent: true } do %>
<%= form_with url: filter_url, method: :get, class: "display-contents", data: {
controller: "form",
turbo_frame: "cards_container",
filter_settings_target: "form",
action: "turbo:submit-end->filter-settings#resetIfNoFiltering",
turbo_action: "advance" } do |form| %>
<%= hidden_field_tag :expand_all, true, disabled: !user_filtering.expanded?, data: { toggle_enable_target: "element" } %>
controller: "expandable-on-native",
expandable_on_native_auto_expand_selector_value: "[data-filter-show=true]" } do %>
<%= tag.summary "Toggle filters", class: "btn btn--plain margin-block-end", data: { bridge__buttons_target: "button", bridge_title: "Toggle filters", bridge_icon_url: bridge_icon("funnel") } %>
<%= tag.aside \
class: class_names("filters margin-block-end", { "filters--expanded": user_filtering.expanded? }),
data: {
controller: "toggle-enable toggle-class filter-settings dialog-manager",
toggle_class_toggle_class: "filters--expanded",
filter_settings_filters_set_class: "filters--has-filters-set",
filter_settings_no_filtering_url_value: no_filtering_url,
filter_settings_refresh_url_value: settings_refresh_path,
filter_settings_cards_url_value: cards_path,
turbo_permanent: true } do %>
<%= form_with url: filter_url, method: :get, class: "display-contents", data: {
controller: "form",
turbo_frame: "cards_container",
filter_settings_target: "form",
action: "turbo:submit-end->filter-settings#resetIfNoFiltering",
turbo_action: "advance" } do |form| %>
<%= hidden_field_tag :expand_all, true, disabled: !user_filtering.expanded?, data: { toggle_enable_target: "element" } %>
<%= yield form if block_given? %>
<%= render "filters/settings/terms", filter: user_filtering.filter, form: form do %>
<%= yield form if block_given? %>
<% end %>
<%= render "filters/settings/controls", user_filtering: user_filtering, form: form %>
<%= render "filters/settings/toggle", user_filtering: user_filtering %>
<% end %>
<%= render "filters/settings/manage", user_filtering: user_filtering, no_filtering_url: no_filtering_url %>
<%= render "filters/settings/terms", filter: user_filtering.filter, form: form do %>
<%= yield form if block_given? %>
<% end %>
<%= render "filters/settings/controls", user_filtering: user_filtering, form: form %>
<%= render "filters/settings/toggle", user_filtering: user_filtering %>
<% end %>
<%= render "filters/settings/manage", user_filtering: user_filtering, no_filtering_url: no_filtering_url %>
<% end %>
<% end %>
+2 -1
View File
@@ -1,8 +1,9 @@
<%= form.search_field "terms[]", placeholder: "Filter these cards… [F]", class: "filter__terms input txt-x-small",
autofocus: false, autocomplete: :off, autocorrect: "off", data: {
"1p-ignore": "true",
controller: "hotkey",
controller: "hotkey touch-placeholder",
filter_settings_target: "field",
touch_placeholder_placeholder_value: "Filter these cards…",
action: "keydown.f@document->hotkey#focus input->filter-settings#resetIfNoFiltering input->form#debouncedSubmit keydown.enter->form#submitToTopTarget blur->form#submitToTopTarget" } %>
<% if filter.terms.present? %>
+2 -2
View File
@@ -3,12 +3,12 @@
<figure class="lightbox__figure">
<img src="" class="lightbox__image" data-lightbox-target="zoomedImage" />
<figcaption class="lightbox__caption" data-lightbox-target="caption"></figcaption>
<figcaption class="lightbox__caption" data-lightbox-target="caption" tabindex="-1" data-dialog-target="focusTouch">&nbsp</figcaption>
</figure>
<div class="lightbox__actions">
<%= yield %>
<button class="btn fill-white" data-action="dialog#close" data-controller="hotkey tooltip">
<button class="btn fill-white" data-action="dialog#close" data-controller="hotkey tooltip" data-dialog-target="focusMouse">
<%= icon_tag "remove" %>
<span class="for-screen-reader">Close (esc)</span>
</button>
@@ -1,6 +1,6 @@
<h1 class="title">Download your Fizzy data</h1>
<p class="subtitle">Your Fizzy data export has finished processing and is ready to download.</p>
<p><%= link_to "Download your data", account_export_url(@export) %></p>
<p><%= link_to "Download your data", export_download_url(@export) %></p>
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email" %>.</p>
@@ -1,3 +1,3 @@
Your Fizzy data export has finished processing and is ready to download.
Download your data: <%= account_export_url(@export) %>
Download your data: <%= export_download_url(@export) %>
@@ -0,0 +1,5 @@
<p class="subtitle">Your import of <%= @account.name %> is complete!</p>
<p><%= link_to "Go to your account", landing_url(script_name: @account.slug) %></p>
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email" %>.</p>
@@ -0,0 +1,3 @@
Your import of <%= @account.name %> is complete!
Here's the URL: <%= landing_url(script_name: @account.slug) %>

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