Merge branch 'main' into dialog-ux-improvements
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 @@
|
||||
<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 |
@@ -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);
|
||||
|
||||
@@ -3,5 +3,12 @@
|
||||
.hide-on-android {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Filters
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.filters {
|
||||
--text-x-small: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
@layer components {
|
||||
.expandable-on-native {
|
||||
body:not([data-platform~=native]) & {
|
||||
&::details-content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -95,12 +95,12 @@
|
||||
display: none;
|
||||
|
||||
.notifications--on & {
|
||||
display: block;
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.notifications__off-message {
|
||||
display: block;
|
||||
display: inline;
|
||||
|
||||
.notifications--on & {
|
||||
display: none;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = " "
|
||||
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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-lnk" %>.</p>
|
||||
<div>That download link has expired. You’ll need to <%= link_to "request a new export", account_settings_path, class: "txt-link" %>.</div>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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 %>
|
||||
@@ -1,7 +1,8 @@
|
||||
<header>
|
||||
<h2 class="divider txt-large">Auto close</h2>
|
||||
<p class="margin-none-block-start">Fizzy doesn’t 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 doesn’t 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>
|
||||
|
||||
@@ -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, we’ll 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>We‘ll email you a link to download the file when it‘s 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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 won’t 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,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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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,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 %>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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"> </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
Reference in New Issue
Block a user