diff --git a/Gemfile b/Gemfile
index fbe6dc72f..5c3b0ff65 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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"
diff --git a/app/assets/stylesheets/bubbles.css b/app/assets/stylesheets/bubbles.css
index 9b7f88d5e..864f0d34d 100644
--- a/app/assets/stylesheets/bubbles.css
+++ b/app/assets/stylesheets/bubbles.css
@@ -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) & {
diff --git a/app/assets/stylesheets/utilities.css b/app/assets/stylesheets/utilities.css
index 761a24b5d..a512aca62 100644
--- a/app/assets/stylesheets/utilities.css
+++ b/app/assets/stylesheets/utilities.css
@@ -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 */
diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb
new file mode 100644
index 000000000..c3fd4f066
--- /dev/null
+++ b/app/helpers/time_helper.rb
@@ -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
diff --git a/app/javascript/controllers/divider_controller.js b/app/javascript/controllers/divider_controller.js
new file mode 100644
index 000000000..058bcb6a0
--- /dev/null
+++ b/app/javascript/controllers/divider_controller.js
@@ -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)
+ }
+}
diff --git a/app/javascript/controllers/local_time_controller.js b/app/javascript/controllers/local_time_controller.js
new file mode 100644
index 000000000..445545a76
--- /dev/null
+++ b/app/javascript/controllers/local_time_controller.js
@@ -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`
+ }
+}
diff --git a/app/javascript/controllers/query_merger_controller.js b/app/javascript/controllers/query_merger_controller.js
deleted file mode 100644
index 3681578e3..000000000
--- a/app/javascript/controllers/query_merger_controller.js
+++ /dev/null
@@ -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)
- }
-}
diff --git a/app/views/bubbles/_publish.html.erb b/app/views/bubbles/_publish.html.erb
index 0f7af76c3..1079e1b0b 100644
--- a/app/views/bubbles/_publish.html.erb
+++ b/app/views/bubbles/_publish.html.erb
@@ -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 %>
Publish
<% end %>
diff --git a/app/views/bubbles/index.html.erb b/app/views/bubbles/index.html.erb
index 814530d10..5dff555d4 100644
--- a/app/views/bubbles/index.html.erb
+++ b/app/views/bubbles/index.html.erb
@@ -34,20 +34,15 @@
-
-
- Top 10
-
-
-
-
<%= render partial: "bubbles/list/bubble", collection: @bubbles, cached: true %>
+ <%= render "bubbles/list/divider", filter: @filter %>