Merge branch 'main' into latest-activity
* main: Don't round up in time distance expression Tidy Gemfile Banner for draft state Drafts should always use the dashed outline wherever they're displayed Unnecessary parens Use distance to nearest edge Simplify overlap logic Fix divider drag jankiness Use cache-friendly "time ago" positionedClass -> installedClass Pull out moveDividerTo Before comes before After DividerElement -> Divider Clean up divider-controller Drag bubble divider
This commit is contained in:
@@ -16,6 +16,7 @@ gem "bootsnap", require: false
|
||||
gem "puma", ">= 5.0"
|
||||
gem "solid_cable", ">= 3.0"
|
||||
gem "solid_cache", "~> 1.0"
|
||||
gem "solid_queue", "~> 1.1"
|
||||
gem "sqlite3", ">= 2.0"
|
||||
gem "thruster", require: false
|
||||
|
||||
@@ -44,5 +45,3 @@ group :test do
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
end
|
||||
|
||||
gem "solid_queue", "~> 1.1"
|
||||
|
||||
@@ -366,18 +366,24 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
li {
|
||||
border-radius: 0.6em;
|
||||
list-style: none;
|
||||
padding: 0.5em var(--inline-space);
|
||||
position: relative;
|
||||
transition: background-color 200ms ease-out;
|
||||
ul {
|
||||
li {
|
||||
border-radius: 0.6em;
|
||||
list-style: none;
|
||||
padding: 0.5em var(--inline-space);
|
||||
position: relative;
|
||||
transition: background-color 200ms ease-out;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: color(from var(--bubble-color) srgb r g b / 0.15);
|
||||
border: 0;
|
||||
border-radius: 0.6em;
|
||||
&:not(.dragging) {
|
||||
li {
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
background-color: color(from var(--bubble-color) srgb r g b / 0.15);
|
||||
border: 0;
|
||||
border-radius: 0.6em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,13 +403,29 @@
|
||||
--divider-color: var(--color-subtle-dark);
|
||||
|
||||
font-size: 1rem;
|
||||
cursor: grab;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.bubbles-list__divider--installed {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.divider-drag-image {
|
||||
background-color: var(--color-link);
|
||||
border-radius: 1rem;
|
||||
color: var(--color-ink-reversed);
|
||||
inset: auto 100% 100% auto;
|
||||
line-height: 2rem;
|
||||
padding: 0 1rem;
|
||||
position: fixed;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
.btn--reversed.bubbles-list__handle {
|
||||
--btn-background: var(--divider-color);
|
||||
|
||||
font-size: 0.9rem;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.bubbles-list__count {
|
||||
@@ -417,6 +439,10 @@
|
||||
flex-grow: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.drafted & {
|
||||
color: color(from var(--color-ink) srgb r g b / 0.5);
|
||||
}
|
||||
|
||||
&::after {
|
||||
border-block-end: 1px dotted var(--color-subtle-dark);
|
||||
content: "";
|
||||
@@ -426,9 +452,12 @@
|
||||
}
|
||||
|
||||
.bubble__shape {
|
||||
background-color: var(--bubble-color);
|
||||
background-color: var(--bubble-background-color, var(--bubble-color));
|
||||
block-size: var(--bubble-size, 1.2em);
|
||||
border-color: var(--bubble-border-color, var(--bubble-color));
|
||||
border-radius: var(--bubble-shape);
|
||||
border-style: var(--bubble-border-style, solid);
|
||||
border-width: var(--bubble-border-width, 0);
|
||||
inline-size: var(--bubble-size, 1.2em);
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
@@ -438,13 +467,17 @@
|
||||
|
||||
.bubble & {
|
||||
--bubble-size: 100%;
|
||||
|
||||
background-color: color(from var(--bubble-color) srgb r g b / 0.15);
|
||||
border: var(--bubble-border-width) solid var(--bubble-color);
|
||||
--bubble-background-color: color(from var(--bubble-color) srgb r g b / 0.15);
|
||||
}
|
||||
|
||||
.bubble.drafted & {
|
||||
border-style: dashed;
|
||||
.drafted & {
|
||||
--bubble-border-style: dashed;
|
||||
|
||||
.bubbles-list & {
|
||||
--bubble-background-color: color(from var(--bubble-color) srgb r g b / 0.15);
|
||||
--bubble-border-color: var(--bubble-color);
|
||||
--bubble-border-width: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bubble:has(.bubble__link) & {
|
||||
|
||||
@@ -168,8 +168,10 @@
|
||||
.translucent { opacity: var(--opacity, 0.5); }
|
||||
|
||||
/* Borders */
|
||||
.border { border: var(--border-size, 1px) solid var(--border-color, var(--color-subtle)); }
|
||||
.border-top { border-top: var(--border-size, 1px) solid var(--border-color, var(--color-subtle)); }
|
||||
.border { border: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
|
||||
.border-block { border-block: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
|
||||
.border-bottom { border-block-end: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
|
||||
.border-top { border-block-start: var(--border-size, 1px) var(--border-style, solid) var(--border-color, var(--color-subtle)); }
|
||||
.borderless { border: 0; }
|
||||
|
||||
/* Border radius */
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module TimeHelper
|
||||
def local_datetime_tag(datetime, style: :time, **attributes)
|
||||
tag.time **attributes, datetime: datetime.iso8601, data: { local_time_target: style }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
const MOVE_ITEM_DATA_TYPE = "x-fizzy/move"
|
||||
const DIVIDER_ITEM_NODE_NAME = "LI"
|
||||
const OVERLAP_THRESHOLD = 0.25
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "divider", "dragImage", "count" ]
|
||||
static classes = [ "installed", "dragging" ]
|
||||
static values = { startCount: Number, maxCount: Number }
|
||||
|
||||
connect() {
|
||||
this.install()
|
||||
}
|
||||
|
||||
install() {
|
||||
this.#moveDividerTo(this.startCountValue)
|
||||
this.dividerTarget.classList.add(this.installedClass)
|
||||
}
|
||||
|
||||
configureDrag(event) {
|
||||
if (event.target == this.dividerTarget) {
|
||||
event.dataTransfer.dropEffect = "move"
|
||||
event.dataTransfer.setData(MOVE_ITEM_DATA_TYPE, event.target)
|
||||
event.dataTransfer.setDragImage(this.dragImageTarget, 0, 0)
|
||||
this.element.classList.add(this.draggingClass)
|
||||
}
|
||||
}
|
||||
|
||||
acceptDrop(event) {
|
||||
if (event.dataTransfer.types.includes(MOVE_ITEM_DATA_TYPE)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
moveDivider(event) {
|
||||
if (event.target.nodeName == DIVIDER_ITEM_NODE_NAME) {
|
||||
const rect = this.dividerTarget.getBoundingClientRect()
|
||||
const distanceToTop = Math.abs(event.clientY - rect.top)
|
||||
const distanceToBottom = Math.abs(event.clientY - (rect.top + rect.height))
|
||||
const distanceToNearestEdge = Math.min(distanceToTop, distanceToBottom)
|
||||
const overlap = distanceToNearestEdge / rect.height
|
||||
|
||||
if (overlap > OVERLAP_THRESHOLD) {
|
||||
this.#moveDividerTo(this.#items.indexOf(event.target))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop() {
|
||||
this.element.classList.remove(this.draggingClass)
|
||||
}
|
||||
|
||||
#moveDividerTo(index) {
|
||||
if (index <= this.maxCountValue) {
|
||||
if (this.#dividerIndex < index) {
|
||||
this.#positionDividerAfter(index)
|
||||
} else if (this.#dividerIndex > index) {
|
||||
this.#positionDividerBefore(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#positionDividerBefore(index) {
|
||||
const position = Math.max(index, 1)
|
||||
this.#items[position].before(this.dividerTarget)
|
||||
this.countTarget.textContent = position
|
||||
}
|
||||
|
||||
#positionDividerAfter(index) {
|
||||
const position = Math.min(index, this.#items.length - 1, this.maxCountValue)
|
||||
this.#items[position].after(this.dividerTarget)
|
||||
this.countTarget.textContent = position
|
||||
}
|
||||
|
||||
get #items() {
|
||||
return Array.from(this.element.children)
|
||||
}
|
||||
|
||||
get #dividerIndex() {
|
||||
return this.#items.indexOf(this.dividerTarget)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["time", "date", "datetime", "ago"]
|
||||
|
||||
initialize() {
|
||||
this.timeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short" })
|
||||
this.dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "long" })
|
||||
this.dateTimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", dateStyle: "short" })
|
||||
this.agoFormatter = new AgoFormatter()
|
||||
}
|
||||
|
||||
timeTargetConnected(target) {
|
||||
this.#formatTime(this.timeFormatter, target)
|
||||
}
|
||||
|
||||
dateTargetConnected(target) {
|
||||
this.#formatTime(this.dateFormatter, target)
|
||||
}
|
||||
|
||||
datetimeTargetConnected(target) {
|
||||
this.#formatTime(this.dateTimeFormatter, target)
|
||||
}
|
||||
|
||||
agoTargetConnected(target) {
|
||||
this.#formatTime(this.agoFormatter, target)
|
||||
}
|
||||
|
||||
#formatTime(formatter, target) {
|
||||
const dt = new Date(target.getAttribute("datetime"))
|
||||
target.textContent = formatter.format(dt)
|
||||
target.title = this.dateTimeFormatter.format(dt)
|
||||
}
|
||||
}
|
||||
|
||||
class AgoFormatter {
|
||||
format(dt) {
|
||||
const now = new Date()
|
||||
const seconds = (now - dt) / 1000
|
||||
const minutes = seconds / 60
|
||||
const hours = minutes / 60
|
||||
const days = hours / 24
|
||||
const weeks = days / 7
|
||||
const months = days / (365 / 12)
|
||||
const years = days / 365
|
||||
|
||||
if (years >= 1) return this.#pluralize("year", years)
|
||||
if (months >= 1) return this.#pluralize("month", months)
|
||||
if (weeks >= 1) return this.#pluralize("week", weeks)
|
||||
if (days >= 1) return this.#pluralize("day", days)
|
||||
if (hours >= 1) return this.#pluralize("hour", hours)
|
||||
if (minutes >= 1) return this.#pluralize("minute", minutes)
|
||||
|
||||
return "Less than a minute ago"
|
||||
}
|
||||
|
||||
#pluralize(word, quantity) {
|
||||
quantity = Math.floor(quantity)
|
||||
const suffix = (quantity === 1) ? "" : "s"
|
||||
return `${quantity} ${word}${suffix} ago`
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// FIXME: Can we do this without a controller? https://github.com/basecamp/fizzy/pull/130#discussion_r1833094616
|
||||
export default class extends Controller {
|
||||
merge({ params: { key, value } }) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set(key, value)
|
||||
Turbo.visit(url)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
<%= button_to bucket_bubble_publish_path(bubble.bucket, bubble), class: "btn btn--positive full-width justify-start borderless" do %>
|
||||
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24 %>
|
||||
<%= button_to bucket_bubble_publish_path(bubble.bucket, bubble), class: "btn txt-small btn--link" do %>
|
||||
<span>Publish</span>
|
||||
<% end %>
|
||||
|
||||
@@ -34,20 +34,15 @@
|
||||
</section>
|
||||
|
||||
<section class="bubbles-list unpad-inline center margin-block-start">
|
||||
<ul class="unpad margin-none flex flex-column txt-align-start center">
|
||||
<li class="bubbles-list__divider flex align-center gap-half full-width">
|
||||
<button class="bubbles-list__handle btn btn--reversed">
|
||||
<%= image_tag "drag.svg", aria: { hidden: true }, size: 16 %>
|
||||
<span class="for-screen-reader">Drag to change number of bubbles shown above</span>
|
||||
</button>
|
||||
<hr class="separator--horizontal flex-item-grow">
|
||||
<strong class="bubbles-list__count border-radius">
|
||||
Top 10
|
||||
</strong>
|
||||
<hr class="separator--horizontal flex-item-grow">
|
||||
<span></span>
|
||||
</li>
|
||||
<ul class="unpad margin-none flex flex-column txt-align-start center"
|
||||
data-controller="divider"
|
||||
data-divider-start-count-value="10"
|
||||
data-divider-max-count-value="10"
|
||||
data-divider-installed-class="bubbles-list__divider--installed"
|
||||
data-divider-dragging-class="dragging"
|
||||
data-action="turbo:morph@document->divider#install dragstart->divider#configureDrag dragenter->divider#acceptDrop dragover->divider#acceptDrop dragover->divider#moveDivider drop->divider#drop">
|
||||
<%= render partial: "bubbles/list/bubble", collection: @bubbles, cached: true %>
|
||||
<%= render "bubbles/list/divider", filter: @filter %>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<li class="flex align-center gap-half margin-none" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %>"
|
||||
<li class="<%= class_names("flex align-center gap-half margin-none", drafted: bubble.drafted?) %>" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %>"
|
||||
data-controller="animation" data-animation-play-class="bubble--wobble" data-animation-play-on-load-value="true" data-action="mouseover->animation#play">
|
||||
<div class="bubble__shape flex-item-no-shrink"></div>
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<li class="bubbles-list__divider flex align-center gap-half full-width" draggable="true" data-divider-target="divider">
|
||||
<div class="divider-drag-image" data-divider-target="dragImage">Drag up or down</div>
|
||||
<button class="bubbles-list__handle btn btn--reversed">
|
||||
<%= image_tag "drag.svg", aria: { hidden: true }, size: 16 %>
|
||||
<span class="for-screen-reader">Drag to change number of bubbles shown above</span>
|
||||
</button>
|
||||
<hr class="separator--horizontal flex-item-grow">
|
||||
<strong class="bubbles-list__count border-radius">
|
||||
Top <span data-divider-target="count">10</span>
|
||||
</strong>
|
||||
<hr class="separator--horizontal flex-item-grow">
|
||||
<span></span>
|
||||
</li>
|
||||
@@ -1,6 +1,14 @@
|
||||
<% @page_title = @bubble.title %>
|
||||
|
||||
<% content_for :header do %>
|
||||
<% if @bubble.drafted? %>
|
||||
<div class="flex align-center gap-half fill-selected justify-center border-block margin-block-end"
|
||||
style="--border-color: var(--color-selected-dark); --border-style: dashed; view-transition-name: draft-banner;">
|
||||
This a draft, it’s only visible to you.
|
||||
<%= render "bubbles/publish", bubble: @bubble %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<nav class="align-start">
|
||||
<%= link_to "#", class: "btn flex-item-justify-start", data: { controller: "hotkey back-navigation", action: "keydown.esc@document->hotkey#click", back_navigation_fallback_destination_value: bubbles_path(bucket_id: @bucket) } do %>
|
||||
<%= image_tag "arrow-left.svg", aria: { hidden: true }, size: 24 %>
|
||||
@@ -56,9 +64,7 @@
|
||||
<% end %>
|
||||
|
||||
<hr class="separator--horizontal full-width" style="--border-color: var(--color-subtle);" />
|
||||
<% if @bubble.drafted? %>
|
||||
<%= render "bubbles/publish", bubble: @bubble %>
|
||||
<% else %>
|
||||
<% unless @bubble.drafted? %>
|
||||
<%= render "bubbles/pop_toggle", bubble: @bubble %>
|
||||
<button class="btn full-width justify-start borderless">
|
||||
<%= image_tag "picture-double.svg", aria: { hidden: true }, size: 24 %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div name="assignments" class="position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<div class="position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="btn full-width justify-start borderless" data-action="click->dialog#open:stop">
|
||||
<%= image_tag "person.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span>Assign to someone</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div name="assignments" class="position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<div class="position-relative" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
|
||||
<button class="btn full-width justify-start borderless" data-action="click->dialog#open:stop">
|
||||
<%= image_tag "tag.svg", aria: { hidden: true }, size: 24 %>
|
||||
<span>Tag this</span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<%= link_to bubbles_path(bucket_ids: [ bucket ]), class: "border border-radius margin-block-end-half windshield__container flex justify-center align-center position-relative" do %>
|
||||
<div class="windshield bucket__windshield flex flex-wrap gap justify-center align-end" style="view-transition-name: windshield_<%= bucket.id %>">
|
||||
<% bucket.bubbles.ordered_by_activity.limit(10).each do |bubble| %>
|
||||
<div class="bubble" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %> <%= bubble_size(bubble) %>">
|
||||
<div class="<%= class_names("bubble", drafted: bubble.drafted?) %>" style="--bubble-color: <%= bubble.color %>; <%= bubble_rotation(bubble) %> <%= bubble_size(bubble) %>">
|
||||
<span class="bubble__shape"></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
|
||||
<body data-controller="lightbox">
|
||||
<body data-controller="lightbox local-time">
|
||||
<header id="header">
|
||||
<a href="#main-content" class="skip-navigation btn">Skip to main content</a>
|
||||
<%= yield :header %>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<div class="flex flex-column txt-tight-lines">
|
||||
<strong><%= notification_title(notification) %></strong>
|
||||
<%= notification_body(notification) %> · <%= time_ago_in_words(notification.created_at) %> ago
|
||||
<div><%= notification_body(notification) %> · <%= local_datetime_tag(notification.created_at, style: :ago) %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
Reference in New Issue
Block a user