Merge branch 'main' into fix-blurred-nav-menu-in-safari
This commit is contained in:
@@ -1 +1 @@
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="3.25" cy="12" r="3.25"/><circle cx="12" cy="12" r="3.25"/><circle cx="20.75" cy="12" r="3.25"/></svg>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="3"/><circle cx="4" cy="12" r="3"/><circle cx="20" cy="12" r="3"/></svg>
|
||||
|
Before Width: | Height: | Size: 173 B After Width: | Height: | Size: 158 B |
@@ -18,6 +18,7 @@
|
||||
padding: var(--btn-padding, 0.5em 1.1em);
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
transition: 100ms ease-out;
|
||||
transition-property: background-color, border, box-shadow, color, opacity, scale;
|
||||
|
||||
@@ -127,7 +128,7 @@
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.btn--circle-mobile .icon--mobile-only {
|
||||
.btn .icon--mobile-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -271,4 +272,26 @@
|
||||
inline-size: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button utilities
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
:is([data-platform~=mobile], [data-platform~=native]) {
|
||||
.btn--ensure-tap-target-size {
|
||||
--tap-target-z-index: 1;
|
||||
--tap-target-min-size: 44px;
|
||||
|
||||
z-index: var(--tap-target-z-index);
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
block-size: var(--tap-target-min-size);
|
||||
inline-size: var(--tap-target-min-size);
|
||||
inset: calc(50% - var(--tap-target-min-size) / 2) auto auto calc(50% - var(--tap-target-min-size) / 2);
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +286,7 @@
|
||||
inset-inline-end: 0;
|
||||
position: absolute;
|
||||
translate: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@@ -769,13 +770,16 @@
|
||||
--panel-padding: 2ch;
|
||||
--panel-size: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap);
|
||||
inset-block-start: 0;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
&[open] {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* On Deck (Not Now)
|
||||
@@ -819,6 +823,10 @@
|
||||
position: absolute;
|
||||
translate: 20% -20%;
|
||||
z-index: 1;
|
||||
|
||||
@media (max-width: 639px) {
|
||||
translate: 20% 0%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Maybe column: position bubble relative to the title, not the container */
|
||||
|
||||
@@ -36,20 +36,10 @@
|
||||
|
||||
@media (max-width: 639px) {
|
||||
&:has(.card__closed) {
|
||||
padding-block-start: 2rem;
|
||||
}
|
||||
padding-block-start: 4.5rem;
|
||||
|
||||
&:has(.reactions .reaction) {
|
||||
.card__meta-avatars--assignees {
|
||||
margin-inline-end: unset;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:has(.reactions .reaction)) {
|
||||
&:has(.card__closed) {
|
||||
.card__meta-text--assignees {
|
||||
margin-inline-end: calc(var(--btn-size) + var(--meta-spacer-block));
|
||||
}
|
||||
.card-perma__bg {
|
||||
box-shadow: 0 -2rem 0 0 var(--color-container);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +100,7 @@
|
||||
@media (max-width: 639px) {
|
||||
flex-direction: column;
|
||||
padding-block: var(--card-padding-block) calc(var(--card-padding-block) * 1.5);
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +177,7 @@
|
||||
}
|
||||
|
||||
.card__meta-avatars--assignees {
|
||||
margin-inline: 0 calc(var(--btn-size) + var(--meta-spacer-block));
|
||||
margin-inline: 0;
|
||||
margin-block-start: var(--meta-spacer-block);
|
||||
}
|
||||
|
||||
@@ -232,6 +223,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.card__closed {
|
||||
@media (max-width: 639px) {
|
||||
inset: auto 0 100% auto;
|
||||
translate: 0 30%;
|
||||
scale: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
.card__footer {
|
||||
--btn-size: 2.5rem;
|
||||
|
||||
@@ -251,8 +250,12 @@
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
display: grid;
|
||||
font-size: var(--text-x-small);
|
||||
position: relative;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
"meta bg-zoom"
|
||||
"meta reactions";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,17 +289,11 @@
|
||||
}
|
||||
|
||||
&:not(:has(.reaction)) {
|
||||
margin: 0;
|
||||
|
||||
.reactions__trigger {
|
||||
--btn-border-color: var(--color-ink-light);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,10 @@
|
||||
line-height: 1.15;
|
||||
text-wrap: balance;
|
||||
|
||||
&.overflow-line-clamp {
|
||||
text-wrap: unset; /* text-wrap: balance breaks -webkit-line-clamp in Safari */
|
||||
}
|
||||
|
||||
.card-field__title {
|
||||
overflow: hidden; /* prevent scrolling on windows */
|
||||
padding-block: var(--autosize-block-padding);
|
||||
@@ -398,6 +402,7 @@
|
||||
position: absolute;
|
||||
rotate: 5deg;
|
||||
transform-origin: top right;
|
||||
z-index: 2;
|
||||
|
||||
.cards & {
|
||||
display: none;
|
||||
@@ -409,12 +414,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
inset: auto calc(var(--card-padding-inline) * -0.5) 100% auto;
|
||||
translate: 0 40%;
|
||||
scale: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
.card:has(.card__closed),
|
||||
|
||||
@@ -84,6 +84,17 @@
|
||||
.reactions {
|
||||
margin-block-start: var(--block-space-half);
|
||||
margin-inline: calc(var(--column-gap) / -1);
|
||||
|
||||
&:not(:has(.reaction)) {
|
||||
inset-block-end: var(--comment-padding-block);
|
||||
inset-inline-end: calc(var(--comment-padding-inline) / 2);
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
inset-inline-end: calc(var(--comment-padding-inline) / 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@layer components {
|
||||
.flash {
|
||||
display: flex;
|
||||
inset-block-start: var(--block-space);
|
||||
inset-block-start: calc(var(--block-space) + var(--custom-safe-inset-top));
|
||||
inset-inline-start: 50%;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
@@ -16,7 +16,9 @@
|
||||
color: var(--flash-color, var(--color-ink-inverted));
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-medium);
|
||||
inline-size: max-content;
|
||||
margin: 0 auto;
|
||||
max-inline-size: 90vw;
|
||||
padding: 0.7em 1.4em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
|
||||
--lexxy-color-canvas: var(--color-canvas);
|
||||
|
||||
--lexxy-color-accent-dark: var(--color-accent-dark);
|
||||
--lexxy-color-accent-medium: var(--color-accent-medium);
|
||||
--lexxy-color-accent-light: var(--color-accent-light);
|
||||
--lexxy-color-accent-lightest: var(--color-accent-lightest);
|
||||
--lexxy-color-accent-dark: var(--color-ink-dark);
|
||||
--lexxy-color-accent-medium: var(--color-ink-medium);
|
||||
--lexxy-color-accent-light: var(--color-ink-light);
|
||||
--lexxy-color-accent-lightest: var(--color-ink-lighter);
|
||||
|
||||
--lexxy-color-red: oklch(var(--lch-red-medium));
|
||||
--lexxy-color-green: oklch(var(--lch-green-medium));
|
||||
@@ -38,6 +38,8 @@
|
||||
--lexxy-color-table-cell-selected-bg: var(--lexxy-color-selected);
|
||||
--lexxy-color-table-cell-toggle: var(--lexxy-color-selected);
|
||||
--lexxy-color-table-cell-remove: oklch(var(--lch-red-medium) / 20%);
|
||||
|
||||
--lexxy-focus-ring-offset: 2px;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -139,7 +141,9 @@
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
padding-inline-start: 1ch;
|
||||
&:not(.lexxy-prompt-menu) {
|
||||
padding-inline-start: 1ch;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
|
||||
@@ -20,6 +20,16 @@
|
||||
/* Header
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.header {
|
||||
padding-block-start: var(--custom-safe-inset-top);
|
||||
}
|
||||
|
||||
.header--mobile-actions-stack {
|
||||
.header__title {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header:is(
|
||||
:not(:has(.header__title, .header__actions)),
|
||||
:not(:has(.header__title, .header__actions--end)):has(.header__actions--start .btn--back:only-child)
|
||||
@@ -68,13 +78,32 @@
|
||||
|
||||
.card-perma--draft {
|
||||
.card-perma__bg {
|
||||
box-shadow: 0 calc(100vh + 100px) 0 100vh var(--color-container);
|
||||
padding-inline: 0;
|
||||
padding-block-start: 0;
|
||||
background-color: unset;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(to bottom, var(--color-canvas), var(--card-bg-color));
|
||||
box-shadow: 0 101vh 0 100vh var(--card-bg-color);
|
||||
}
|
||||
|
||||
.card__board {
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
}
|
||||
|
||||
.card-perma__notch--bottom {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Search
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.search {
|
||||
overscroll-behavior: auto;
|
||||
}
|
||||
|
||||
.search__title {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -69,11 +69,11 @@
|
||||
0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%),
|
||||
0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%),
|
||||
0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: var(--nav-section-gap);
|
||||
grid-template-rows: auto 1fr auto;
|
||||
inset: 0 0 auto 0;
|
||||
margin-inline: auto;
|
||||
max-block-size: calc(100vh - var(--block-space) - var(--footer-height));
|
||||
max-block-size: calc(100dvh - var(--block-space) - var(--footer-height));
|
||||
overflow: hidden;
|
||||
padding-block-end: 0;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
@@ -82,7 +82,7 @@
|
||||
z-index: var(--z-nav);
|
||||
|
||||
@media (max-height: 668px) {
|
||||
max-block-size: calc(100vh - var(--block-space));
|
||||
max-block-size: calc(100dvh - var(--block-space));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -232,8 +232,7 @@
|
||||
/* Settings
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.settings__user-filter .input,
|
||||
.settings__user-list-tips {
|
||||
.settings__user-filter .input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: var(--inline-space-half);
|
||||
inline-size: 100%;
|
||||
z-index: 3;
|
||||
|
||||
&:has([open]) {
|
||||
z-index: var(--z-popup);
|
||||
@@ -17,14 +18,6 @@
|
||||
|
||||
&:not(:has(.reaction)) {
|
||||
inline-size: auto;
|
||||
inset-block-end: var(--comment-padding-block);
|
||||
inset-inline-end: calc(var(--comment-padding-inline) / 2);
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
inset-inline-end: calc(var(--comment-padding-inline) / 3);
|
||||
}
|
||||
|
||||
.reactions__list {
|
||||
display: none;
|
||||
|
||||
@@ -117,7 +117,7 @@ summary {
|
||||
|
||||
.search-perma {
|
||||
.search__form > label,
|
||||
.search__reset {
|
||||
.search__form:has(.search__input:placeholder-shown) .search__reset {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
--settings-spacer: var(--block-space);
|
||||
--settings-item-padding-inline: 0.5ch;
|
||||
|
||||
display: grid;
|
||||
align-items: start;
|
||||
display: flex;
|
||||
gap: calc(var(--settings-spacer) * 2);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
max-inline-size: min(100ch, 100%);
|
||||
|
||||
@media (min-width: 960px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
flex-direction: row;
|
||||
|
||||
.settings__panel {
|
||||
max-inline-size: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,10 +62,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Users
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.settings__section--users {
|
||||
.settings__section:has(.settings__scrollable-list) {
|
||||
@media (min-width: 640px) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -66,24 +70,8 @@
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
.settings__user-filter {
|
||||
--btn-size: 3.5ch;
|
||||
--avatar-size: var(--btn-size);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--settings-spacer) / 2);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings__user-filter--bg {
|
||||
background-color: var(--color-ink-lightest);
|
||||
border-radius: 0.5em;
|
||||
margin-block: 0;
|
||||
padding: var(--settings-spacer);
|
||||
}
|
||||
|
||||
.settings__user-list {
|
||||
.settings__scrollable-list {
|
||||
flex: 1 1 auto;
|
||||
list-style: none;
|
||||
margin: calc(var(--settings-spacer) / -4) calc(-1 * var(--settings-item-padding-inline));
|
||||
@@ -116,15 +104,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings__user-list-tips {
|
||||
border-top: 1px solid var(--color-ink-light);
|
||||
color: var(--color-ink-dark);
|
||||
font-size: var(--text-small);
|
||||
padding-block-start: var(--settings-spacer);
|
||||
/* Users
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
.settings__user-filter--bg & {
|
||||
margin-inline: calc(-1 * var(--settings-spacer));
|
||||
padding-inline: var(--settings-spacer);
|
||||
}
|
||||
.settings__user-filter {
|
||||
--btn-size: 3.5ch;
|
||||
--avatar-size: var(--btn-size);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--settings-spacer) / 2);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings__user-filter--bg {
|
||||
background-color: var(--color-ink-lightest);
|
||||
border-radius: 0.5em;
|
||||
margin-block: 0;
|
||||
padding: var(--settings-spacer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ class BoardsController < ApplicationController
|
||||
before_action :ensure_permission_to_admin_board, only: %i[ update destroy ]
|
||||
|
||||
def index
|
||||
set_page_and_extract_portion_from Current.user.boards
|
||||
set_page_and_extract_portion_from Current.user.boards.ordered_by_recently_accessed.includes(creator: :identity)
|
||||
fresh_when etag: @page.records
|
||||
end
|
||||
|
||||
def show
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class ClientConfigurationsController < ApplicationController
|
||||
skip_before_action :require_account, :require_authentication
|
||||
allow_unauthorized_access
|
||||
|
||||
def show
|
||||
expires_in 1.minute, public: true
|
||||
|
||||
render action: client_configuration_name
|
||||
end
|
||||
|
||||
private
|
||||
def client_configuration_name
|
||||
"#{params.require(:platform)}_v#{params.require(:version)}"
|
||||
end
|
||||
end
|
||||
@@ -7,14 +7,10 @@ module RequestForgeryProtection
|
||||
|
||||
private
|
||||
def verified_via_header_only?
|
||||
super || allowed_api_request? || allowed_insecure_context_request?
|
||||
super || allowed_api_request?
|
||||
end
|
||||
|
||||
def allowed_api_request?
|
||||
sec_fetch_site_value.nil? && request.format.json?
|
||||
end
|
||||
|
||||
def allowed_insecure_context_request?
|
||||
sec_fetch_site_value.nil? && !request.ssl? && !Rails.configuration.force_ssl
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
class Notifications::TraysController < ApplicationController
|
||||
MAX_ENTRIES_LIMIT = 100
|
||||
|
||||
def show
|
||||
@notifications = Current.user.notifications.unread.preloaded.ordered.limit(100)
|
||||
@notifications = unread_notifications
|
||||
if include_read?
|
||||
@notifications += read_notifications
|
||||
end
|
||||
|
||||
# Invalidate on the whole set instead of the unread set since the max updated at in the unread set
|
||||
# can stay the same when reading old notifications.
|
||||
fresh_when Current.user.notifications
|
||||
fresh_when etag: [ Current.user.notifications, include_read? ]
|
||||
end
|
||||
|
||||
private
|
||||
def unread_notifications
|
||||
Current.user.notifications.unread.preloaded.ordered.limit(MAX_ENTRIES_LIMIT)
|
||||
end
|
||||
|
||||
def read_notifications
|
||||
Current.user.notifications.read.preloaded.ordered.limit(MAX_ENTRIES_LIMIT)
|
||||
end
|
||||
|
||||
def include_read?
|
||||
ActiveModel::Type::Boolean.new.cast(params[:include_read])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,10 +2,12 @@ class SearchesController < ApplicationController
|
||||
include Turbo::DriveHelper
|
||||
|
||||
def show
|
||||
if card = Current.user.accessible_cards.find_by_id(params[:q])
|
||||
@query = params[:q].blank? ? nil : params[:q]
|
||||
|
||||
if card = Current.user.accessible_cards.find_by_id(@query)
|
||||
@card = card
|
||||
else
|
||||
set_page_and_extract_portion_from Current.user.search(params[:q])
|
||||
set_page_and_extract_portion_from Current.user.search(@query)
|
||||
@recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -29,7 +29,7 @@ class Signups::CompletionsController < ApplicationController
|
||||
redirect_to landing_url(script_name: @signup.account.slug)
|
||||
end
|
||||
|
||||
format.json { render json: { account_id: @signup.account.id }, status: :created }
|
||||
format.json { head :created }
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ class UsersController < ApplicationController
|
||||
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
|
||||
|
||||
def index
|
||||
set_page_and_extract_portion_from Current.account.users.active.alphabetically
|
||||
set_page_and_extract_portion_from Current.account.users.active.alphabetically.includes(:identity)
|
||||
end
|
||||
|
||||
def show
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
require "zlib"
|
||||
|
||||
module AvatarsHelper
|
||||
AVATAR_COLORS = %w[
|
||||
#AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
|
||||
#736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
|
||||
]
|
||||
|
||||
def avatar_background_color(user)
|
||||
AVATAR_COLORS[Zlib.crc32(user.to_param) % AVATAR_COLORS.size]
|
||||
user.avatar_background_color
|
||||
end
|
||||
|
||||
def avatar_tag(user, hidden_for_screen_reader: false, **options)
|
||||
|
||||
@@ -9,12 +9,4 @@ module BoardsHelper
|
||||
icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader")
|
||||
end
|
||||
end
|
||||
|
||||
def bridged_button_to_board(board)
|
||||
link_to "Go to #{board.name}", board, hidden: true, data: {
|
||||
bridge__buttons_target: "button",
|
||||
bridge_icon_url: bridge_icon("board"),
|
||||
bridge_title: "Go to #{board.name}"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,4 +2,36 @@ module BridgeHelper
|
||||
def bridge_icon(name)
|
||||
asset_url("#{name}.svg")
|
||||
end
|
||||
|
||||
def bridged_button_to_board(board)
|
||||
link_to "Go to #{board.name}", board, hidden: true, data: {
|
||||
bridge__buttons_target: "button",
|
||||
bridge_icon_url: bridge_icon("board"),
|
||||
bridge_title: "Go to #{board.name}"
|
||||
}
|
||||
end
|
||||
|
||||
def bridged_share_url_button(description = nil)
|
||||
tag.button "Share", hidden: true, data: {
|
||||
controller: "bridge--share",
|
||||
action: "bridge--share#shareUrl",
|
||||
bridge__overflow_menu_target: "item",
|
||||
bridge_title: "Share",
|
||||
bridge_share_description: description
|
||||
}
|
||||
end
|
||||
|
||||
def bridge_share_card_description(card)
|
||||
date_added = card.created_at.strftime("%b %e")
|
||||
date_updated = card.last_active_at.strftime("%b %e")
|
||||
author = card.creator.familiar_name
|
||||
assignees = card.assignees.any? ? "assigned to #{card.assignees.map { |assignee| h assignee.familiar_name }.to_sentence}" : "not assigned"
|
||||
"Added #{date_added} by #{author} and #{assignees}. Updated #{date_updated}"
|
||||
end
|
||||
|
||||
def bridge_share_board_description(board)
|
||||
count_open = board.cards.active.count
|
||||
count_in_stream = board.cards.awaiting_triage.count
|
||||
"#{count_open} open cards, #{count_in_stream} in MAYBE?"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ module My::MenuHelper
|
||||
aria: { activedescendant: "" },
|
||||
data: {
|
||||
"1p-ignore": "true",
|
||||
dialog_target: "focusMouse",
|
||||
filter_target: "input",
|
||||
nav_section_expander_target: "input",
|
||||
navigable_list_target: "input",
|
||||
|
||||
@@ -22,15 +22,6 @@ export default class extends Controller {
|
||||
this.#hideItem(this.searchTarget)
|
||||
}
|
||||
|
||||
clearInput() {
|
||||
if (this.searchInputTarget.value) {
|
||||
this.searchInputTarget.value = ""
|
||||
this.searchInputTarget.focus()
|
||||
} else {
|
||||
this.reset()
|
||||
}
|
||||
}
|
||||
|
||||
showModalAndSubmit(event) {
|
||||
this.showModal()
|
||||
this.formTarget.requestSubmit()
|
||||
|
||||
@@ -5,6 +5,26 @@ export default class extends BridgeComponent {
|
||||
static component = "buttons"
|
||||
static targets = [ "button" ]
|
||||
|
||||
connect() {
|
||||
super.connect()
|
||||
|
||||
if (!this.beforeUnloadHandler) {
|
||||
this.beforeUnloadHandler = this.handleBeforeUnload.bind(this)
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", this.beforeUnloadHandler)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
super.disconnect()
|
||||
|
||||
if (this.beforeUnloadHandler) {
|
||||
window.removeEventListener("beforeunload", this.beforeUnloadHandler)
|
||||
}
|
||||
|
||||
this.notifyBridgeOfDisconnect()
|
||||
}
|
||||
|
||||
buttonTargetConnected() {
|
||||
this.notifyBridgeOfConnect()
|
||||
}
|
||||
@@ -27,6 +47,14 @@ export default class extends BridgeComponent {
|
||||
})
|
||||
}
|
||||
|
||||
notifyBridgeOfDisconnect() {
|
||||
this.send("disconnect")
|
||||
}
|
||||
|
||||
handleBeforeUnload() {
|
||||
this.notifyBridgeOfDisconnect()
|
||||
}
|
||||
|
||||
#clickButton(message) {
|
||||
const selectedIndex = message.data.selectedIndex
|
||||
this.#enabledButtonTargets[selectedIndex].click()
|
||||
|
||||
@@ -6,13 +6,31 @@ export default class extends BridgeComponent {
|
||||
static targets = [ "submit", "cancel" ]
|
||||
static values = { submitTitle: String }
|
||||
|
||||
connect() {
|
||||
super.connect()
|
||||
|
||||
if (!this.beforeUnloadHandler) {
|
||||
this.beforeUnloadHandler = this.handleBeforeUnload.bind(this)
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", this.beforeUnloadHandler)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
super.disconnect()
|
||||
|
||||
if (this.beforeUnloadHandler) {
|
||||
window.removeEventListener("beforeunload", this.beforeUnloadHandler)
|
||||
}
|
||||
}
|
||||
|
||||
submitTargetConnected() {
|
||||
this.notifyBridgeOfConnect()
|
||||
this.#observeSubmitTarget()
|
||||
}
|
||||
|
||||
submitTargetDisconnected() {
|
||||
this.notifyBridgeOfDisonnect()
|
||||
this.notifyBridgeOfDisconnect()
|
||||
this.submitObserver?.disconnect()
|
||||
}
|
||||
|
||||
@@ -37,7 +55,7 @@ export default class extends BridgeComponent {
|
||||
}
|
||||
}
|
||||
|
||||
notifyBridgeOfDisonnect() {
|
||||
notifyBridgeOfDisconnect() {
|
||||
this.send("disconnect")
|
||||
}
|
||||
|
||||
@@ -49,6 +67,10 @@ export default class extends BridgeComponent {
|
||||
this.send("submitEnd")
|
||||
}
|
||||
|
||||
handleBeforeUnload() {
|
||||
this.notifyBridgeOfDisconnect()
|
||||
}
|
||||
|
||||
#observeSubmitTarget() {
|
||||
this.submitObserver = new MutationObserver(() => {
|
||||
this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled")
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
|
||||
|
||||
export default class extends BridgeComponent {
|
||||
static component = "share"
|
||||
|
||||
shareUrl() {
|
||||
const description = this.bridgeElement.bridgeAttribute("share-description")
|
||||
this.send("shareUrl", {
|
||||
title: document.title,
|
||||
url: window.location.href,
|
||||
description: description
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["searchInput"]
|
||||
|
||||
clearInput() {
|
||||
if (this.searchInputTarget.value) {
|
||||
this.searchInputTarget.value = ""
|
||||
this.searchInputTarget.focus()
|
||||
} else {
|
||||
this.dispatch("reset")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { BridgeElement } from "@hotwired/hotwire-native-bridge"
|
||||
BridgeElement.prototype.getButton = function() {
|
||||
return {
|
||||
title: this.title,
|
||||
icon: this.getIcon()
|
||||
icon: this.getIcon(),
|
||||
displayTitle: this.getDisplayTitle(),
|
||||
displayAsPrimaryAction: this.getDisplayAsPrimaryAction()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +18,11 @@ BridgeElement.prototype.getIcon = function() {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
BridgeElement.prototype.getDisplayTitle = function() {
|
||||
return !!this.bridgeAttribute(`display-title`)
|
||||
}
|
||||
|
||||
BridgeElement.prototype.getDisplayAsPrimaryAction = function() {
|
||||
return !!this.bridgeAttribute(`display-as-primary-action`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class Notification::PushJob < ApplicationJob
|
||||
def perform(notification)
|
||||
notification.push
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,6 @@ class PushNotificationJob < ApplicationJob
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(notification)
|
||||
NotificationPusher.new(notification).push
|
||||
notification.push
|
||||
end
|
||||
end
|
||||
|
||||
@@ -31,7 +31,7 @@ class Account::DataTransfer::AccountRecordSet < Account::DataTransfer::RecordSet
|
||||
account_data = load(files.first)
|
||||
join_code_data = account_data.delete("join_code")
|
||||
|
||||
account.update!(name: account_data.fetch("name"))
|
||||
account.update!(name: account_data.fetch("name"), cards_count: account_data.fetch("cards_count", 0))
|
||||
account.join_code.update!(join_code_data.slice("usage_count", "usage_limit"))
|
||||
account.join_code.update(code: join_code_data.fetch("code"))
|
||||
end
|
||||
|
||||
+54
-11
@@ -1,4 +1,4 @@
|
||||
class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer::RecordSet
|
||||
class Account::DataTransfer::ActionText::RichTextRecordSet < Account::DataTransfer::RecordSet
|
||||
ATTRIBUTES = %w[
|
||||
account_id
|
||||
body
|
||||
@@ -11,16 +11,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
|
||||
].freeze
|
||||
|
||||
def initialize(account)
|
||||
super(account: account, model: ActionText::RichText)
|
||||
super(account: account, model: ::ActionText::RichText)
|
||||
end
|
||||
|
||||
private
|
||||
def records
|
||||
ActionText::RichText.where(account: account)
|
||||
::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))
|
||||
data = rich_text.as_json.merge("body" => transform_body_for_export(rich_text.body))
|
||||
zip.add_file "data/action_text_rich_texts/#{rich_text.id}.json", data.to_json
|
||||
end
|
||||
|
||||
@@ -31,11 +31,11 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
|
||||
def import_batch(files)
|
||||
batch_data = files.map do |file|
|
||||
data = load(file)
|
||||
data["body"] = convert_gids_to_sgids(data["body"])
|
||||
data["body"] = transform_body_for_import(data["body"])
|
||||
data.slice(*ATTRIBUTES).merge("account_id" => account.id)
|
||||
end
|
||||
|
||||
ActionText::RichText.insert_all!(batch_data)
|
||||
::ActionText::RichText.insert_all!(batch_data)
|
||||
end
|
||||
|
||||
def check_record(file_path)
|
||||
@@ -54,11 +54,16 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
|
||||
check_associations_dont_exist(data)
|
||||
end
|
||||
|
||||
def convert_sgids_to_gids(content)
|
||||
def transform_body_for_export(content)
|
||||
return nil if content.blank?
|
||||
|
||||
html = convert_sgids_to_gids(content)
|
||||
relativize_urls(html)
|
||||
end
|
||||
|
||||
def convert_sgids_to_gids(content)
|
||||
content.send(:attachment_nodes).each do |node|
|
||||
sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME)
|
||||
sgid = SignedGlobalID.parse(node["sgid"], for: ::ActionText::Attachable::LOCATOR_NAME)
|
||||
|
||||
record = begin
|
||||
sgid&.find
|
||||
@@ -75,11 +80,35 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
|
||||
content.fragment.source.to_html
|
||||
end
|
||||
|
||||
def convert_gids_to_sgids(html)
|
||||
return html if html.blank?
|
||||
def relativize_urls(html)
|
||||
host = Rails.application.routes.default_url_options[:host]
|
||||
return html unless host
|
||||
|
||||
fragment = Nokogiri::HTML.fragment(html)
|
||||
|
||||
fragment.css("a[href]").each do |link|
|
||||
uri = URI.parse(link["href"]) rescue nil
|
||||
|
||||
if uri.respond_to?(:host) && uri.host == host
|
||||
link["href"] = uri.path
|
||||
link["href"] += "?#{uri.query}" if uri.query
|
||||
link["href"] += "##{uri.fragment}" if uri.fragment
|
||||
end
|
||||
end
|
||||
|
||||
fragment.to_html
|
||||
end
|
||||
|
||||
def transform_body_for_import(body)
|
||||
return body if body.blank?
|
||||
|
||||
Nokogiri::HTML.fragment(body)
|
||||
.then { convert_gids_to_sgids(it) }
|
||||
.then { replace_account_slugs(it) }
|
||||
.to_html
|
||||
end
|
||||
|
||||
def convert_gids_to_sgids(fragment)
|
||||
fragment.css("action-text-attachment[gid]").each do |node|
|
||||
gid = GlobalID.parse(node["gid"])
|
||||
|
||||
@@ -97,6 +126,20 @@ class Account::DataTransfer::ActionTextRichTextRecordSet < Account::DataTransfer
|
||||
end
|
||||
end
|
||||
|
||||
fragment.to_html
|
||||
fragment
|
||||
end
|
||||
|
||||
def replace_account_slugs(fragment)
|
||||
fragment.css("a[href]").each do |link|
|
||||
match = link["href"].match(AccountSlug::PATH_INFO_MATCH)
|
||||
|
||||
if match
|
||||
path = match.post_match.presence || "/"
|
||||
valid_path = Rails.application.routes.recognize_path(path) rescue nil
|
||||
link["href"] = "#{account.slug}#{path}" if valid_path
|
||||
end
|
||||
end
|
||||
|
||||
fragment
|
||||
end
|
||||
end
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer::RecordSet
|
||||
class Account::DataTransfer::ActiveStorage::BlobRecordSet < Account::DataTransfer::RecordSet
|
||||
def initialize(account)
|
||||
super(
|
||||
account: account,
|
||||
model: ActiveStorage::Blob,
|
||||
attributes: ActiveStorage::Blob.column_names - %w[service_name]
|
||||
model: ::ActiveStorage::Blob,
|
||||
attributes: ::ActiveStorage::Blob.column_names - %w[service_name]
|
||||
)
|
||||
end
|
||||
|
||||
@@ -13,7 +13,7 @@ class Account::DataTransfer::ActiveStorageBlobRecordSet < Account::DataTransfer:
|
||||
data = load(file)
|
||||
data.slice(*attributes).merge(
|
||||
"account_id" => account.id,
|
||||
"service_name" => ActiveStorage::Blob.service.name
|
||||
"service_name" => ::ActiveStorage::Blob.service.name
|
||||
)
|
||||
end
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSet
|
||||
class Account::DataTransfer::ActiveStorage::FileRecordSet < Account::DataTransfer::RecordSet
|
||||
def initialize(account)
|
||||
super(account: account, model: ActiveStorage::Blob)
|
||||
super(account: account, model: ::ActiveStorage::Blob)
|
||||
end
|
||||
|
||||
private
|
||||
def records
|
||||
ActiveStorage::Blob.where(account: account)
|
||||
::ActiveStorage::Blob.where(account: account)
|
||||
end
|
||||
|
||||
def export_record(blob)
|
||||
@@ -23,7 +23,7 @@ class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSe
|
||||
def import_batch(files)
|
||||
files.each do |file|
|
||||
key = File.basename(file)
|
||||
blob = ActiveStorage::Blob.find_by(key: key, account: account)
|
||||
blob = ::ActiveStorage::Blob.find_by(key: key, account: account)
|
||||
next unless blob
|
||||
|
||||
zip.read(file) do |stream|
|
||||
@@ -35,7 +35,7 @@ class Account::DataTransfer::BlobFileRecordSet < Account::DataTransfer::RecordSe
|
||||
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)
|
||||
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
|
||||
@@ -35,10 +35,10 @@ class Account::DataTransfer::Manifest
|
||||
::Filter, ::Webhook::DelinquencyTracker, ::Event,
|
||||
::Notification, ::Notification::Bundle, ::Webhook::Delivery
|
||||
),
|
||||
Account::DataTransfer::ActiveStorageBlobRecordSet.new(account),
|
||||
Account::DataTransfer::ActiveStorage::BlobRecordSet.new(account),
|
||||
*build_record_sets(::ActiveStorage::Attachment),
|
||||
Account::DataTransfer::ActionTextRichTextRecordSet.new(account),
|
||||
Account::DataTransfer::BlobFileRecordSet.new(account)
|
||||
Account::DataTransfer::ActionText::RichTextRecordSet.new(account),
|
||||
Account::DataTransfer::ActiveStorage::FileRecordSet.new(account)
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class Account::Import < ApplicationRecord
|
||||
end
|
||||
|
||||
add_importer_to_all_access_boards
|
||||
reconcile_cards_count
|
||||
reconcile_account_storage
|
||||
|
||||
mark_completed
|
||||
@@ -78,6 +79,10 @@ class Account::Import < ApplicationRecord
|
||||
ImportMailer.failed(self).deliver_later
|
||||
end
|
||||
|
||||
def reconcile_cards_count
|
||||
account.update_column :cards_count, [ account.cards_count, account.cards.maximum(:number).to_i ].max
|
||||
end
|
||||
|
||||
def add_importer_to_all_access_boards
|
||||
importer = account.users.find_by!(identity: identity)
|
||||
|
||||
|
||||
@@ -17,11 +17,11 @@ module Mentions
|
||||
rich_text_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ")
|
||||
end
|
||||
|
||||
private
|
||||
def scan_mentionees
|
||||
mentionees_from_attachments & mentionable_users
|
||||
end
|
||||
def scan_mentionees
|
||||
mentionees_from_attachments & mentionable_users
|
||||
end
|
||||
|
||||
private
|
||||
def mentionees_from_attachments
|
||||
rich_text_associations.flat_map { send(it.name)&.body&.attachments&.collect { it.attachable } }.compact
|
||||
end
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
module PushNotifiable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_create_commit :push_notification_later
|
||||
after_update_commit :push_notification_later, if: :source_id_previously_changed?
|
||||
end
|
||||
|
||||
private
|
||||
def push_notification_later
|
||||
PushNotificationJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
@@ -28,6 +28,10 @@ class Identity < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def users_with_active_accounts
|
||||
users.joins(:account).merge(Account.active).includes(:account)
|
||||
end
|
||||
|
||||
private
|
||||
def deactivate_users
|
||||
users.find_each(&:deactivate)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Notification < ApplicationRecord
|
||||
include PushNotifiable
|
||||
include Notification::Pushable
|
||||
|
||||
belongs_to :account, default: -> { user.account }
|
||||
belongs_to :user
|
||||
@@ -10,7 +10,13 @@ class Notification < ApplicationRecord
|
||||
scope :unread, -> { where(read_at: nil) }
|
||||
scope :read, -> { where.not(read_at: nil) }
|
||||
scope :ordered, -> { order(read_at: :desc, updated_at: :desc) }
|
||||
scope :preloaded, -> { preload(:card, :creator, :account, source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]) }
|
||||
scope :preloaded, -> {
|
||||
preload(
|
||||
:creator, :account,
|
||||
card: [ :board, :column, :closure, :not_now ],
|
||||
source: [ :board, :creator, { eventable: [ :closure, :board, :assignments ] } ]
|
||||
)
|
||||
}
|
||||
|
||||
before_validation :set_card
|
||||
after_create :bundle
|
||||
@@ -21,6 +27,7 @@ class Notification < ApplicationRecord
|
||||
after_destroy_commit -> { broadcast_remove_to user, :notifications }
|
||||
|
||||
delegate :notifiable_target, to: :source
|
||||
delegate :identity, to: :user
|
||||
|
||||
class << self
|
||||
def read_all
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
class Notification::DefaultPayload
|
||||
attr_reader :notification
|
||||
|
||||
delegate :card, to: :notification
|
||||
|
||||
def initialize(notification)
|
||||
@notification = notification
|
||||
end
|
||||
|
||||
def to_h
|
||||
{ title: title, body: body, url: url }
|
||||
end
|
||||
|
||||
def title
|
||||
"New notification"
|
||||
end
|
||||
|
||||
def body
|
||||
"You have a new notification"
|
||||
end
|
||||
|
||||
def url
|
||||
notifications_url
|
||||
end
|
||||
|
||||
def category
|
||||
"default"
|
||||
end
|
||||
|
||||
def high_priority?
|
||||
false
|
||||
end
|
||||
|
||||
def base_url
|
||||
Rails.application.routes.url_helpers.root_url(**url_options.except(:script_name)).chomp("/")
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
Rails.application.routes.url_helpers.user_avatar_url(notification.creator, **url_options)
|
||||
end
|
||||
|
||||
private
|
||||
def card_url(card)
|
||||
Rails.application.routes.url_helpers.card_url(card, **url_options)
|
||||
end
|
||||
|
||||
def notifications_url
|
||||
Rails.application.routes.url_helpers.notifications_url(**url_options)
|
||||
end
|
||||
|
||||
def url_options
|
||||
base_options = Rails.application.routes.default_url_options.presence ||
|
||||
Rails.application.config.action_mailer.default_url_options ||
|
||||
{}
|
||||
base_options.merge(script_name: notification.account.slug)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
class Notification::EventPayload < Notification::DefaultPayload
|
||||
include ExcerptHelper
|
||||
|
||||
def title
|
||||
case event.action
|
||||
when "comment_created"
|
||||
"RE: #{card_title}"
|
||||
else
|
||||
card_title
|
||||
end
|
||||
end
|
||||
|
||||
def body
|
||||
case event.action
|
||||
when "comment_created"
|
||||
format_excerpt(event.eventable.body, length: 200)
|
||||
when "card_assigned"
|
||||
"Assigned to you by #{event.creator.name}"
|
||||
when "card_published"
|
||||
"Added by #{event.creator.name}"
|
||||
when "card_closed"
|
||||
card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}"
|
||||
when "card_reopened"
|
||||
"Reopened by #{event.creator.name}"
|
||||
when "card_triaged"
|
||||
if column_name.present?
|
||||
"Moved to #{column_name} by #{event.creator.name}"
|
||||
else
|
||||
"Moved by #{event.creator.name}"
|
||||
end
|
||||
when "card_sent_back_to_triage"
|
||||
"Moved back to Maybe? by #{event.creator.name}"
|
||||
when "card_board_changed", "card_collection_changed"
|
||||
if new_location_name.present?
|
||||
"Moved to #{new_location_name} by #{event.creator.name}"
|
||||
else
|
||||
"Moved by #{event.creator.name}"
|
||||
end
|
||||
when "card_title_changed"
|
||||
if new_title.present?
|
||||
"Renamed to #{new_title} by #{event.creator.name}"
|
||||
else
|
||||
"Renamed by #{event.creator.name}"
|
||||
end
|
||||
when "card_postponed"
|
||||
"Moved to Not Now by #{event.creator.name}"
|
||||
when "card_auto_postponed"
|
||||
"Moved to Not Now due to inactivity"
|
||||
else
|
||||
"Updated by #{event.creator.name}"
|
||||
end
|
||||
end
|
||||
|
||||
def url
|
||||
case event.action
|
||||
when "comment_created"
|
||||
card_url_with_comment_anchor(event.eventable)
|
||||
else
|
||||
card_url(card)
|
||||
end
|
||||
end
|
||||
|
||||
def category
|
||||
case event.action
|
||||
when "card_assigned" then "assignment"
|
||||
when "comment_created" then "comment"
|
||||
else "card"
|
||||
end
|
||||
end
|
||||
|
||||
def high_priority?
|
||||
event.action.card_assigned?
|
||||
end
|
||||
|
||||
private
|
||||
def event
|
||||
notification.source
|
||||
end
|
||||
|
||||
def card_title
|
||||
card.title.presence || "Card #{card.number}"
|
||||
end
|
||||
|
||||
def event_particulars
|
||||
event.particulars.dig("particulars") || {}
|
||||
end
|
||||
|
||||
def column_name
|
||||
event_particulars["column"]
|
||||
end
|
||||
|
||||
def new_location_name
|
||||
event_particulars["new_board"] || event_particulars["new_collection"]
|
||||
end
|
||||
|
||||
def new_title
|
||||
event_particulars["new_title"]
|
||||
end
|
||||
|
||||
def card_url_with_comment_anchor(comment)
|
||||
Rails.application.routes.url_helpers.card_url(
|
||||
comment.card,
|
||||
anchor: ActionView::RecordIdentifier.dom_id(comment),
|
||||
**url_options
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,28 @@
|
||||
class Notification::MentionPayload < Notification::DefaultPayload
|
||||
include ExcerptHelper
|
||||
|
||||
def title
|
||||
"#{mention.mentioner.first_name} mentioned you"
|
||||
end
|
||||
|
||||
def body
|
||||
format_excerpt(mention.source.mentionable_content, length: 200)
|
||||
end
|
||||
|
||||
def url
|
||||
card_url(card)
|
||||
end
|
||||
|
||||
def category
|
||||
"mention"
|
||||
end
|
||||
|
||||
def high_priority?
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
def mention
|
||||
notification.source
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
class Notification::PushTarget
|
||||
attr_reader :notification
|
||||
|
||||
delegate :card, to: :notification
|
||||
|
||||
def self.process(notification)
|
||||
new(notification).process
|
||||
end
|
||||
|
||||
def initialize(notification)
|
||||
@notification = notification
|
||||
end
|
||||
|
||||
def process
|
||||
raise NotImplementedError
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class Notification::PushTarget::Web < Notification::PushTarget
|
||||
def process
|
||||
if subscriptions.any?
|
||||
Rails.configuration.x.web_push_pool.queue(notification.payload.to_h, subscriptions)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def subscriptions
|
||||
@subscriptions ||= notification.user.push_subscriptions
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
module Notification::Pushable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
class_attribute :push_targets, default: []
|
||||
|
||||
after_save_commit :push_later, if: :source_id_previously_changed?
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def register_push_target(target)
|
||||
target = resolve_push_target(target)
|
||||
push_targets << target unless push_targets.include?(target)
|
||||
end
|
||||
|
||||
private
|
||||
def resolve_push_target(target)
|
||||
if target.is_a?(Symbol)
|
||||
"Notification::PushTarget::#{target.to_s.classify}".constantize
|
||||
else
|
||||
target
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def push_later
|
||||
Notification::PushJob.perform_later(self)
|
||||
end
|
||||
|
||||
def push
|
||||
return unless pushable?
|
||||
|
||||
self.class.push_targets.each { |target| push_to(target) }
|
||||
end
|
||||
|
||||
def payload
|
||||
"Notification::#{payload_type}Payload".constantize.new(self)
|
||||
end
|
||||
|
||||
private
|
||||
def pushable?
|
||||
!creator.system? && user.active? && account.active?
|
||||
end
|
||||
|
||||
def push_to(target)
|
||||
target.process(self)
|
||||
end
|
||||
|
||||
def payload_type
|
||||
source_type.presence_in(%w[ Event Mention ]) || "Default"
|
||||
end
|
||||
end
|
||||
@@ -1,124 +0,0 @@
|
||||
class NotificationPusher
|
||||
include Rails.application.routes.url_helpers
|
||||
include ExcerptHelper
|
||||
|
||||
attr_reader :notification
|
||||
|
||||
def initialize(notification)
|
||||
@notification = notification
|
||||
end
|
||||
|
||||
def push
|
||||
return unless should_push?
|
||||
|
||||
build_payload.tap do |payload|
|
||||
push_to_user(payload)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def should_push?
|
||||
notification.user.push_subscriptions.any? &&
|
||||
!notification.creator.system? &&
|
||||
notification.user.active? &&
|
||||
notification.account.active?
|
||||
end
|
||||
|
||||
def build_payload
|
||||
case notification.source_type
|
||||
when "Event"
|
||||
build_event_payload
|
||||
when "Mention"
|
||||
build_mention_payload
|
||||
else
|
||||
build_default_payload
|
||||
end
|
||||
end
|
||||
|
||||
def build_event_payload
|
||||
event = notification.source
|
||||
card = event.card
|
||||
|
||||
base_payload = {
|
||||
title: card_notification_title(card),
|
||||
path: card_path(card)
|
||||
}
|
||||
|
||||
case event.action
|
||||
when "comment_created"
|
||||
base_payload.merge(
|
||||
title: "RE: #{base_payload[:title]}",
|
||||
body: comment_notification_body(event),
|
||||
path: card_path_with_comment_anchor(event.eventable)
|
||||
)
|
||||
when "card_assigned"
|
||||
base_payload.merge(
|
||||
body: "Assigned to you by #{event.creator.name}"
|
||||
)
|
||||
when "card_published"
|
||||
base_payload.merge(
|
||||
body: "Added by #{event.creator.name}"
|
||||
)
|
||||
when "card_closed"
|
||||
base_payload.merge(
|
||||
body: card.closure ? "Moved to Done by #{event.creator.name}" : "Closed by #{event.creator.name}"
|
||||
)
|
||||
when "card_reopened"
|
||||
base_payload.merge(
|
||||
body: "Reopened by #{event.creator.name}"
|
||||
)
|
||||
else
|
||||
base_payload.merge(
|
||||
body: event.creator.name
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_mention_payload
|
||||
mention = notification.source
|
||||
card = mention.card
|
||||
|
||||
{
|
||||
title: "#{mention.mentioner.first_name} mentioned you",
|
||||
body: format_excerpt(mention.source.mentionable_content, length: 200),
|
||||
path: card_path(card)
|
||||
}
|
||||
end
|
||||
|
||||
def build_default_payload
|
||||
{
|
||||
title: "New notification",
|
||||
body: "You have a new notification",
|
||||
path: notifications_path(script_name: notification.account.slug)
|
||||
}
|
||||
end
|
||||
|
||||
def push_to_user(payload)
|
||||
subscriptions = notification.user.push_subscriptions
|
||||
enqueue_payload_for_delivery(payload, subscriptions)
|
||||
end
|
||||
|
||||
def enqueue_payload_for_delivery(payload, subscriptions)
|
||||
Rails.configuration.x.web_push_pool.queue(payload, subscriptions)
|
||||
end
|
||||
|
||||
def card_notification_title(card)
|
||||
card.title.presence || "Card #{card.number}"
|
||||
end
|
||||
|
||||
def comment_notification_body(event)
|
||||
format_excerpt(event.eventable.body, length: 200)
|
||||
end
|
||||
|
||||
def card_path(card)
|
||||
Rails.application.routes.url_helpers.card_path(card, script_name: notification.account.slug)
|
||||
end
|
||||
|
||||
def card_path_with_comment_anchor(comment)
|
||||
Rails.application.routes.url_helpers.card_path(
|
||||
comment.card,
|
||||
anchor: ActionView::RecordIdentifier.dom_id(comment),
|
||||
script_name: notification.account.slug
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -8,9 +8,9 @@ class Notifier::CardEventNotifier < Notifier
|
||||
when "card_assigned"
|
||||
source.assignees.excluding(creator)
|
||||
when "card_published"
|
||||
board.watchers.without(creator, *card.mentionees).including(*card.assignees).uniq
|
||||
board.watchers.without(creator, *card.scan_mentionees).including(*card.assignees).uniq
|
||||
when "comment_created"
|
||||
card.watchers.without(creator, *source.eventable.mentionees)
|
||||
card.watchers.without(creator, *source.eventable.scan_mentionees)
|
||||
else
|
||||
board.watchers.without(creator)
|
||||
end
|
||||
|
||||
@@ -3,7 +3,7 @@ class Notifier::CommentEventNotifier < Notifier
|
||||
|
||||
private
|
||||
def recipients
|
||||
card.watchers.without(creator, *source.eventable.mentionees)
|
||||
card.watchers.without(creator, *source.eventable.scan_mentionees)
|
||||
end
|
||||
|
||||
def card
|
||||
|
||||
@@ -5,7 +5,7 @@ module Search::Stemmer
|
||||
|
||||
def stem(value)
|
||||
if value.present?
|
||||
value.gsub(/[^\w\s]/, "").split(/\s+/).map { |word| STEMMER.stem(word.downcase) }.join(" ")
|
||||
value.gsub(/[^\w\s]/, " ").split(/\s+/).map { |word| STEMMER.stem(word.downcase) }.join(" ")
|
||||
else
|
||||
value
|
||||
end
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
require "zlib"
|
||||
|
||||
module User::Avatar
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
ALLOWED_AVATAR_CONTENT_TYPES = %w[ image/jpeg image/png image/gif image/webp ].freeze
|
||||
MAX_AVATAR_DIMENSIONS = { width: 4096, height: 4096 }.freeze
|
||||
AVATAR_COLORS = %w[
|
||||
#AF2E1B #CC6324 #3B4B59 #BFA07A #ED8008 #ED3F1C #BF1B1B #736B1E #D07B53
|
||||
#736356 #AD1D1D #BF7C2A #C09C6F #698F9C #7C956B #5D618F #3B3633 #67695E
|
||||
].freeze
|
||||
|
||||
included do
|
||||
has_one_attached :avatar do |attachable|
|
||||
@@ -22,6 +28,10 @@ module User::Avatar
|
||||
avatar.variable? ? avatar.variant(:thumb) : avatar
|
||||
end
|
||||
|
||||
def avatar_background_color
|
||||
AVATAR_COLORS[Zlib.crc32(to_param) % AVATAR_COLORS.size]
|
||||
end
|
||||
|
||||
# Avatars are always publicly accessible
|
||||
def publicly_accessible?
|
||||
true
|
||||
|
||||
@@ -36,6 +36,7 @@ class Webhook < ApplicationRecord
|
||||
after_create :create_delinquency_tracker!
|
||||
|
||||
normalizes :subscribed_actions, with: ->(value) { Array.wrap(value).map(&:to_s).uniq & PERMITTED_ACTIONS }
|
||||
normalizes :url, with: -> { it.strip }
|
||||
|
||||
validates :name, presence: true
|
||||
validate :validate_url
|
||||
|
||||
@@ -2,7 +2,7 @@ class ZipFile::Reader
|
||||
def initialize(io)
|
||||
@io = io
|
||||
@reader = ZipKit::FileReader.read_zip_structure(io: io)
|
||||
rescue ZipKit::FileReader::InvalidStructure => e
|
||||
rescue ZipKit::FileReader::ReadError, ZipKit::FileReader::MissingEOCD, ZipKit::FileReader::UnsupportedFeature => e
|
||||
raise ZipFile::InvalidFileError, e.message
|
||||
end
|
||||
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
<% if Current.account.cancellable? && Current.user.owner? %>
|
||||
<header class="margin-block-start-double">
|
||||
<h2 class="divider txt-large txt-negative">Cancel account</h2>
|
||||
<p class="margin-none-block">Delete your Fizzy account</p>
|
||||
</header>
|
||||
<section class="settings__section">
|
||||
<header>
|
||||
<h2 class="divider txt-negative">Cancel account</h2>
|
||||
<div>Delete your Fizzy account.</div>
|
||||
</header>
|
||||
|
||||
<div data-controller="dialog" data-dialog-modal-value="true">
|
||||
<button type="button" class="btn btn--negative" data-action="dialog#open">Delete account</button>
|
||||
<div data-controller="dialog" data-dialog-modal-value="true">
|
||||
<button type="button" class="btn btn--negative" data-action="dialog#open">Delete account</button>
|
||||
|
||||
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog">
|
||||
<h2 class="margin-none txt-large txt-negative">Delete your account?</h2>
|
||||
<ul class="txt-align-start">
|
||||
<li>All users, including you, will lose access</li>
|
||||
<% if Current.account.try(:active_subscription) %>
|
||||
<li>Your subscription will be canceled</li>
|
||||
<% end %>
|
||||
<li>After 30 days your data will be permanently deleted</li>
|
||||
</ul>
|
||||
<dialog class="dialog panel panel--wide shadow" data-dialog-target="dialog" style="--panel-size: 48ch;">
|
||||
<h2 class="margin-none txt-large txt-negative">Delete your account?</h2>
|
||||
<ul class="txt-align-start">
|
||||
<li>All users, including you, will lose access</li>
|
||||
<% if Current.account.try(:active_subscription) %>
|
||||
<li>Your subscription will be canceled</li>
|
||||
<% end %>
|
||||
<li>After 30 days, your data will be permanently deleted</li>
|
||||
</ul>
|
||||
|
||||
<div class="flex gap justify-center">
|
||||
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
|
||||
<%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
<div class="flex gap justify-center">
|
||||
<button type="button" class="btn" data-action="dialog#close">Cancel</button>
|
||||
<%= button_to "Delete my account", account_cancellation_path, method: :post, class: "btn btn--negative", form: { data: { action: "submit->dialog#close", turbo: false } } %>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<section class="settings__section settings__section--users">
|
||||
<section class="settings__section">
|
||||
<header>
|
||||
<h2 class="divider">People on this account</h2>
|
||||
</header>
|
||||
@@ -13,7 +13,7 @@
|
||||
<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">
|
||||
<ul class="settings__scrollable-list margin-block-half" data-filter-target="list" role="listbox">
|
||||
<%= render partial: "account/settings/user", collection: users %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
<section class="settings">
|
||||
<div class="settings__panel settings__panel--users panel shadow">
|
||||
<%= form_with model: @board, class: "display-contents", data: {
|
||||
controller: "form boards-form",
|
||||
controller: "form boards-form bridge--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.",
|
||||
action: "turbo:submit-start->boards-form#submitWithWarning" } do |form| %>
|
||||
<%= render "boards/edit/name", form: form, board: @board %>
|
||||
<%= render "boards/edit/users", board: @board, selected_users: @selected_users, unselected_users: @unselected_users, form: form %>
|
||||
|
||||
<button type="submit" id="log_in" class="btn btn--link center txt-normal" <%= "disabled" unless Current.user.can_administer_board?(@board) %>>
|
||||
<button type="submit" id="log_in" class="btn btn--link center txt-normal" data-bridge--form-target="submit" <%= "disabled" unless Current.user.can_administer_board?(@board) %>>
|
||||
<span>Save changes</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="settings__user-list" data-filter-target="list">
|
||||
<ul class="settings__scrollable-list" data-filter-target="list">
|
||||
<%= access_toggles_for selected_users, selected: true, disabled: %>
|
||||
<%= access_toggles_for unselected_users, selected: false, disabled: %>
|
||||
</ul>
|
||||
|
||||
@@ -30,3 +30,5 @@
|
||||
<%= render "boards/show/columns", page: @page, board: @board %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= bridged_share_url_button(bridge_share_board_description(@board)) %>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
navigable_list_auto_select_value: false,
|
||||
navigable_list_auto_scroll_value: false,
|
||||
card_hotkeys_navigable_list_outlet: ".cards__transition-container",
|
||||
native_prevent_pull_to_refresh: true,
|
||||
action: "
|
||||
keydown->navigable-list#navigate
|
||||
keydown->card-hotkeys#handleKeydown
|
||||
|
||||
@@ -8,6 +8,7 @@ json.cache! card do
|
||||
json.tags card.tags.pluck(:title).sort
|
||||
|
||||
json.closed card.closed?
|
||||
json.postponed card.postponed?
|
||||
json.golden card.golden?
|
||||
json.last_active_at card.last_active_at.utc
|
||||
json.created_at card.created_at.utc
|
||||
|
||||
@@ -24,13 +24,16 @@
|
||||
<%= button_to_set_column @card, column %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to "Done", card_closure_path(@card),
|
||||
<%= button_to card_closure_path(@card),
|
||||
class: [ "card__column-name btn", { "card__column-name--current": @card.closed? } ],
|
||||
style: "--column-color: var(--color-card-complete)",
|
||||
disabled: @card.closed?,
|
||||
role: "radio",
|
||||
aria: { checked: @card.closed? },
|
||||
data: { scroll_to_target: @card.closed? ? "target" : nil },
|
||||
form_class: "flex gap-half" %>
|
||||
form_class: "flex gap-half" do %>
|
||||
<%= icon_tag "check", class: "icon--mobile-only" %>
|
||||
Done
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -13,9 +13,9 @@ json.cache! comment do
|
||||
|
||||
json.card do
|
||||
json.id comment.card_id
|
||||
json.url card_url(comment.card_id)
|
||||
json.url card_url(comment.card)
|
||||
end
|
||||
|
||||
json.reactions_url card_comment_reactions_url(comment.card_id, comment.id)
|
||||
json.url card_comment_url(comment.card_id, comment.id)
|
||||
json.reactions_url card_comment_reactions_url(comment.card, comment)
|
||||
json.url card_comment_url(comment.card, comment)
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</figure>
|
||||
|
||||
<div class="comment__content flex-inline flex-column full-width">
|
||||
<div class="comment__body lexxy-content" data-turbo-permanent>
|
||||
<div class="comment__body" data-turbo-permanent>
|
||||
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
|
||||
data: { controller: "form local-save",
|
||||
local_save_key_value: "comment-#{card.id}",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</figure>
|
||||
|
||||
<div class="comment__content flex flex-column flex-item-grow full-width">
|
||||
<div class="comment__body lexxy-content" data-turbo-permanent>
|
||||
<div class="comment__body" data-turbo-permanent>
|
||||
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width",
|
||||
data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
|
||||
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<%= button_to card_closure_path(card), method: :delete, class: "btn btn--plain borderless fill-transparent" do %>
|
||||
<span class="pad-inline-end">Undo</span>
|
||||
<% end %>
|
||||
<%= bridged_button_to_board(card.board) %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="card-perma__notch card-perma__notch--bottom">
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
<% end %>
|
||||
|
||||
<%= button_to card_closure_path(card), class: "btn btn--circle-mobile borderless",
|
||||
data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark as Done", bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" },
|
||||
data: { controller: "hotkey", form_target: "submit", bridge__buttons_target: "button", bridge_title: "Mark done", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("check"), action: "keydown.d@document->hotkey#click" },
|
||||
form: { data: { controller: "form" } } do %>
|
||||
<%= icon_tag "check", class: "icon--mobile-only" %>
|
||||
<span class="overflow-ellipsis">Mark as Done</span>
|
||||
<kbd class="txt-x-small hide-on-touch">d</kbd>
|
||||
<% end %>
|
||||
|
||||
<%= bridged_button_to_board(card.board) %>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<% if card.image.attached? %>
|
||||
<%= button_to card_image_path(card), method: :delete, class: "btn",
|
||||
data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Remove background image" } do %>
|
||||
<%= button_to card_image_path(card), method: :delete, class: "btn hide-on-native",
|
||||
data: { controller: "tooltip" } do %>
|
||||
<%= icon_tag "picture-remove" %>
|
||||
<span class="for-screen-reader">Remove background image</span>
|
||||
<% end %>
|
||||
<% elsif !card.closed? %>
|
||||
<%= form_with model: card, data: { controller: "form" } do |form| %>
|
||||
<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">
|
||||
<label class="card-perma__image-btn btn input--file btn--circle-mobile hide-on-native" data-controller="tooltip">
|
||||
<%= icon_tag "picture-add" %>
|
||||
<span class="for-screen-reader">Add background image</span>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div id="<%= dom_id(card, :tags) %>" class="card__tags">
|
||||
<div data-controller="dialog"
|
||||
data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside mouseenter->dialog#loadLazyFrames" <%= "hidden" if card.closed? %>>
|
||||
<button class="card__tag-picker-button btn card__hide-on-index" style="--btn-background: var(--card-bg-color);"
|
||||
<button class="card__tag-picker-button btn btn--ensure-tap-target-size card__hide-on-index" style="--btn-background: var(--card-bg-color);"
|
||||
data-controller="tooltip hotkey" data-action="click->dialog#open:stop keydown.t@document->hotkey#click">
|
||||
<%= icon_tag "tag" %>
|
||||
<span class="for-screen-reader">Add a tag</span>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 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>
|
||||
data: { controller: "tooltip hotkey", action: "keydown.shift+p@document->hotkey#click", bridge__overflow_menu_target: "item", bridge_title: "Unpin this card" } do %>
|
||||
<%= icon_tag "pinned" %> <span class="for-screen-reader">Unpin this card (shift+p)</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= button_to card_pin_path(card), class: "btn btn--circle-mobile",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
<div class="header__actions header__actions--start">
|
||||
<%= link_back_to_board(@card.board) %>
|
||||
</div>
|
||||
<%= bridged_button_to_board(@card.board) %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream_from @card %>
|
||||
@@ -23,3 +22,5 @@
|
||||
<%= button_to_remove_card_image(@card) if @card.image.attached? %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= bridged_share_url_button(bridge_share_card_description(@card)) %>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"settings": {},
|
||||
"rules": [
|
||||
{
|
||||
"patterns": [
|
||||
".*"
|
||||
],
|
||||
"properties": {
|
||||
"context": "default",
|
||||
"presentation": "default",
|
||||
"query_string_presentation": "replace",
|
||||
"uri": "hotwire://fragment/web",
|
||||
"fallback_uri": "hotwire://fragment/web",
|
||||
"pull_to_refresh_enabled": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/new$",
|
||||
"/new\\?.+$",
|
||||
"/edit$",
|
||||
"/edit\\?.+$",
|
||||
"/cards/[0-9]+/draft",
|
||||
"/notifications/settings",
|
||||
"/account/settings",
|
||||
"/account/join_code"
|
||||
],
|
||||
"properties": {
|
||||
"context": "modal",
|
||||
"pull_to_refresh_enabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/devices$"
|
||||
],
|
||||
"properties": {
|
||||
"context": "modal",
|
||||
"pull_to_refresh_enabled": false,
|
||||
"allow_untenanted_navigation": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/native/login/blank"
|
||||
],
|
||||
"properties": {
|
||||
"uri": "hotwire://fragment/login/blank"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/native/login/email"
|
||||
],
|
||||
"properties": {
|
||||
"uri": "hotwire://fragment/login/email"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/native/login/magic_code"
|
||||
],
|
||||
"properties": {
|
||||
"uri": "hotwire://fragment/login/magic_code"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/native/login/signup_completion"
|
||||
],
|
||||
"properties": {
|
||||
"uri": "hotwire://fragment/login/signup_completion"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/native/settings"
|
||||
],
|
||||
"properties": {
|
||||
"uri": "hotwire://fragment/settings"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/my/pins"
|
||||
],
|
||||
"properties": {
|
||||
"uri": "hotwire://fragment/pins"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/notifications$"
|
||||
],
|
||||
"properties": {
|
||||
"uri": "hotwire://fragment/notifications"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"settings": {
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"patterns": [
|
||||
".*"
|
||||
],
|
||||
"properties": {
|
||||
"context": "default",
|
||||
"query_string_presentation": "replace",
|
||||
"pull_to_refresh_enabled": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/new$",
|
||||
"/new\\?.+$",
|
||||
"/edit$",
|
||||
"/edit\\?.+$",
|
||||
"/accounts$",
|
||||
"/cards/[0-9]+/draft"
|
||||
],
|
||||
"properties": {
|
||||
"context": "modal",
|
||||
"pull_to_refresh_enabled": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/native/my_menu$"
|
||||
],
|
||||
"properties": {
|
||||
"context": "modal",
|
||||
"view_controller": "main_menu"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/native/add_account$"
|
||||
],
|
||||
"properties": {
|
||||
"context": "modal",
|
||||
"view_controller": "login"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/native/login$"
|
||||
],
|
||||
"properties": {
|
||||
"view_controller": "login"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/my/pins$"
|
||||
],
|
||||
"properties": {
|
||||
"view_controller": "pinned"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/notifications/tray$"
|
||||
],
|
||||
"properties": {
|
||||
"view_controller": "notifications"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"internal/devtools/enable*"
|
||||
],
|
||||
"properties": {
|
||||
"context": "modal",
|
||||
"view_controller": "dev_settings_launcher"
|
||||
}
|
||||
},
|
||||
{
|
||||
"patterns": [
|
||||
"/native/settings$"
|
||||
],
|
||||
"properties": {
|
||||
"view_controller": "settings"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="board-tools card">
|
||||
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link",
|
||||
form: { data: { turbo_frame: "_top" } },
|
||||
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_icon_url: bridge_icon("add") } do %>
|
||||
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %>
|
||||
<%= icon_tag "add", class: "show-on-touch" %>
|
||||
<span>Add a card</span>
|
||||
<kbd class="hide-on-touch">C</kbd>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="header__actions header__actions--end">
|
||||
<div>
|
||||
<%= link_to new_board_path, class: "btn btn--link btn--circle-mobile",
|
||||
data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a board", bridge_icon_url: bridge_icon("board") } do %>
|
||||
data: { controller: "hotkey", action: "keydown.b@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add board", bridge_display_title: true, bridge_icon_url: bridge_icon("board") } do %>
|
||||
<%= icon_tag "board" %>
|
||||
<span class="overflow-ellipsis">Add a board</span>
|
||||
<kbd class="hide-on-touch">B</kbd>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="header__actions header__actions--start">
|
||||
<% if board = user_filtering.single_board_or_first %>
|
||||
<%= button_to board_cards_path(board), method: :post, class: "btn btn--link btn--circle-mobile",
|
||||
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add a card", bridge_icon_url: bridge_icon("add") } do %>
|
||||
data: { controller: "hotkey", action: "keydown.c@document->hotkey#click", bridge__buttons_target: "button", bridge_title: "Add card", bridge_display_as_primary_action: true, bridge_display_title: true, bridge_icon_url: bridge_icon("add") } do %>
|
||||
<%= icon_tag "add" %>
|
||||
<span class="overflow-ellipsis">Add a card</span>
|
||||
<kbd class="hide-on-touch">C</kbd>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<%= render "layouts/shared/head" %>
|
||||
|
||||
<body class="public <%= @body_class %>" data-controller="local-time timezone-cookie" data-action="turbo:morph@window->local-time#refreshAll">
|
||||
<body class="public <%= @body_class %>" data-controller="local-time timezone-cookie bridge--title bridge--text-size bridge--insets" data-action="turbo:morph@window->local-time#refreshAll" data-platform="<%= platform.type %>" data-bridge--title-title-value="<%= @page_title %>">
|
||||
<div id="global-container">
|
||||
<header class="header" id="header">
|
||||
<a href="#main" class="header__skip-navigation btn" data-turbo="false">Skip to main content</a>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
json.accounts @identity.users do |user|
|
||||
json.id @identity.id
|
||||
|
||||
json.accounts @identity.users_with_active_accounts do |user|
|
||||
json.partial! "my/identities/account", account: user.account
|
||||
json.user user, partial: "users/user", as: :user
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="flex gap">
|
||||
<div class="flex flex-column full-width justify-center gap-half">
|
||||
<div class="nav__header">
|
||||
<div class="nav__header" tabindex="-1" data-dialog-target="focusTouch">
|
||||
<% if Current.identity.accounts.many? %>
|
||||
<div class="nav__header-title max-width">
|
||||
<div class="overflow-ellipsis"><strong><%= Current.account.name %></strong></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="tray__item tray__item--pin" id="<%= dom_id pin %>" data-navigable-list-target="item">
|
||||
<%= render "cards/display/preview", card: pin.card %>
|
||||
<%= button_to card_pin_path(pin.card), method: :delete, class: "tray__remove-pin-btn btn btn--circle borderless" do %>
|
||||
<%= icon_tag "pinned" %> <span class="for-screen-reader">Un-pin this card</span>
|
||||
<%= icon_tag "pinned" %> <span class="for-screen-reader">Unpin this card</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,19 @@ json.cache! notification do
|
||||
json.read notification.read?
|
||||
json.read_at notification.read_at&.utc
|
||||
json.created_at notification.created_at.utc
|
||||
json.source_type notification.source_type.underscore
|
||||
|
||||
json.partial! "notifications/notification/#{notification.source_type.underscore}/body", notification: notification
|
||||
|
||||
json.creator notification.creator, partial: "users/user", as: :user
|
||||
|
||||
json.card do
|
||||
json.(notification.card, :id, :title, :status)
|
||||
json.(notification.card, :id, :number, :title, :status)
|
||||
json.board_name notification.card.board.name
|
||||
json.closed notification.card.closed?
|
||||
json.postponed notification.card.postponed?
|
||||
json.url card_url(notification.card)
|
||||
json.column notification.card.column, partial: "columns/column", as: :column if notification.card.column
|
||||
end
|
||||
|
||||
json.url notification_url(notification)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="settings__panel settings__panel--users panel shadow">
|
||||
<section class="settings__section">
|
||||
<h2 class="divider">Boards</h2>
|
||||
<div class="settings__user-list flex flex-column gap-half">
|
||||
<div class="settings__scrollable-list flex flex-column gap-half">
|
||||
<%= render partial: "notifications/settings/board", collection: @boards, locals: { user: Current.user } %>
|
||||
</div>
|
||||
</section>
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<div class="settings__panel panel shadow">
|
||||
<%= render "notifications/settings/push_notifications" %>
|
||||
<%= render "notifications/settings/native_devices" if Fizzy.saas? %>
|
||||
<%= render "notifications/settings/email", settings: @settings %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.array! @notifications, partial: "notifications/notification", as: :notification
|
||||
@@ -25,7 +25,7 @@ async function updateBadgeCount({ data: { badge } }) {
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close()
|
||||
|
||||
const url = new URL(event.notification.data.path, self.location.origin).href
|
||||
const url = new URL(event.notification.data.url, self.location.origin).href
|
||||
event.waitUntil(openURL(url))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<%= form_with url: search_path, method: :get, class: "search__form flex align-center justify-center gap-half", data: { bar_target: "form", turbo_frame: defined?(target_turbo_frame) ? target_turbo_frame : nil } do |form| %>
|
||||
<%= form_with url: search_path, method: :get,
|
||||
class: "search__form flex align-center justify-center gap-half",
|
||||
data: {
|
||||
controller: "search-form",
|
||||
action: "search-form:reset->bar#reset",
|
||||
bar_target: "form",
|
||||
turbo_frame: defined?(target_turbo_frame) ? target_turbo_frame : nil } do |form| %>
|
||||
<%= form.label :q, "Search Fizzy", class: "font-weight-black txt-nowrap" %>
|
||||
<%= text_field_tag :q, query_terms,
|
||||
class: "search__input input",
|
||||
@@ -7,9 +13,11 @@
|
||||
autocomplete: "off",
|
||||
autofocus: true,
|
||||
data: {
|
||||
search_form_target: "searchInput",
|
||||
bar_target: "searchInput",
|
||||
action: "keydown.enter->bar#showModalAndSubmit:prevent keydown.esc->bar#reset" } %>
|
||||
<button class="search__reset btn btn--circle borderless" data-action="bar#clearInput">
|
||||
<button hidden>Search</button>
|
||||
<button class="search__reset btn btn--circle borderless" data-action="search-form#clearInput">
|
||||
<%= icon_tag "close" %>
|
||||
<span class="for-screen-reader">Close search</span>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<section class="search">
|
||||
<div class="search__results">
|
||||
<% unless page.used? %>
|
||||
<% if !page.used? && query %>
|
||||
<div class="search__blank-slate blank-slate">
|
||||
No matches
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<% @page_title = params.has_key?(:q) ? "Search results for \"#{params[:q]}\"" : "Search" %>
|
||||
<% @page_title = @query ? "Search results for \"#{@query}\"" : "Search" %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<div class="header__actions header__actions--start">
|
||||
@@ -9,12 +9,12 @@
|
||||
<% end %>
|
||||
|
||||
<div class="search-perma margin-block-start">
|
||||
<%= render "form", query_terms: params[:q] %>
|
||||
<%= render "form", query_terms: @query %>
|
||||
<%= turbo_frame_tag "bar_content" do %>
|
||||
<% if @card %>
|
||||
<%= auto_submit_form_with url: card_path(@card), method: :get, data: { turbo_action: "advance", turbo_frame: "_top", search_redirect: true } %>
|
||||
<% else %>
|
||||
<%= render "results", page: @page %>
|
||||
<%= render "results", page: @page, query: @query %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -5,4 +5,5 @@ json.cache! user do
|
||||
json.created_at user.created_at.utc
|
||||
|
||||
json.url user_url(user)
|
||||
json.avatar_url user_avatar_url(user)
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="panel panel--centered flex flex-column gap-half" style="--panel-size: 45ch;">
|
||||
<div class="panel panel--centered flex flex-column gap-half">
|
||||
<h1 class="txt-x-large font-weight-black margin-none">
|
||||
<%= @page_title %>
|
||||
</h1>
|
||||
@@ -18,7 +18,7 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="margin-none-block-start margin-block-end-half">Enter your new email address, then check your email to confirm the change.</p>
|
||||
<p class="margin-none-block-start margin-block-end-half">Enter your new email address, then check your email to confirm the change.</p>
|
||||
|
||||
<button type="submit" class="btn btn--link center" data-form-target="submit">
|
||||
<span>Continue</span>
|
||||
|
||||
Reference in New Issue
Block a user