Merge branch 'main' into mobile-columns-pt-iii

* main: (55 commits)
  Add padding to upgrade message on larger screens
  Add test coverage for autolinking multiple URLs
  Add "noopener" to autolinks' rel attribute
  Avoid string manipulation when autolinking.
  Only bump z-index when nav is open
  Move nav and related elements above footer
  Delete Dockerfile.dev
  fix: use the right gh-cli arch package (#2232)
  Bump actions/attest-build-provenance from 3.0.0 to 3.1.0 (#2257)
  Bump docker/setup-buildx-action from 3.11.1 to 3.12.0 (#2256)
  Consider user avatars always public
  Implement authorization for Active Storage endpoints
  Update documentation
  Expose the card ID and URL on comments
  Don't run application recurring jobs in betas
  This link is reduntant if you're over the limits
  Add obvious upgrade button when you're out of storage
  Internaly only view
  Target comment elements more precisely
  Better name for helper method
  ...
This commit is contained in:
Andy Smith
2025-12-29 14:07:00 -06:00
90 changed files with 1691 additions and 339 deletions
@@ -0,0 +1,130 @@
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
export default class extends Controller {
static outlets = [ "navigable-list" ]
connect() {
this.morphCompletePromise = null
this.morphCompleteResolver = null
}
handleKeydown(event) {
if (this.#shouldIgnore(event)) return
const handler = this.#keyHandlers[event.key.toLowerCase()]
if (handler) {
handler.call(this, event)
}
}
// Called when turbo:morph completes - resolves our waiting promise
handleMorphComplete() {
if (this.morphCompleteResolver) {
this.morphCompleteResolver()
this.morphCompleteResolver = null
this.morphCompletePromise = null
}
}
// Private
#shouldIgnore(event) {
const target = event.target
return target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
target.closest("input, textarea, [contenteditable], lexxy-editor")
}
get #selectedCard() {
// Find the navigable-list that currently has focus
const focusedList = this.navigableListOutlets.find(list => list.hasFocus)
if (!focusedList) return null
const currentItem = focusedList.currentItem
if (currentItem?.classList.contains("card") && !this.#hotkeysDisabled(focusedList)) {
return { card: currentItem, controller: focusedList }
}
return null
}
async #postponeCard(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardNotNowUrl
if (url) {
event.preventDefault()
await this.#performCardAction(url, selection)
}
}
async #closeCard(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardClosureUrl
if (url) {
event.preventDefault()
await this.#performCardAction(url, selection)
}
}
async #assignToMe(event) {
const selection = this.#selectedCard
if (!selection) return
const url = selection.card.dataset.cardAssignToMeUrl
if (url) {
event.preventDefault()
await post(url, { responseKind: "turbo-stream" })
}
}
async #performCardAction(url, selection) {
const { controller } = selection
const visibleItems = controller.visibleItems
const currentIndex = visibleItems.indexOf(selection.card)
const wasLastItem = currentIndex === visibleItems.length - 1
// Set up promise to wait for morph completion
this.morphCompletePromise = new Promise(resolve => {
this.morphCompleteResolver = resolve
})
await post(url, { responseKind: "turbo-stream" })
// Wait for Turbo Stream morph to complete
await Promise.race([
this.morphCompletePromise,
new Promise(resolve => setTimeout(resolve, 200)) // Fallback timeout
])
// Select the next card (or previous if it was the last)
const newVisibleItems = controller.visibleItems
if (newVisibleItems.length === 0) {
controller.clearSelection()
return
}
if (wasLastItem) {
controller.selectLast()
} else {
const nextIndex = Math.min(currentIndex, newVisibleItems.length - 1)
if (newVisibleItems[nextIndex]) {
await controller.selectItem(newVisibleItems[nextIndex])
}
}
}
#hotkeysDisabled(navigableList) {
return navigableList?.element.dataset.cardHotkeysDisabled === "true"
}
#keyHandlers = {
"["(event) { this.#postponeCard(event) },
"]"(event) { this.#closeCard(event) },
m(event) { this.#assignToMe(event) }
}
}
@@ -5,11 +5,13 @@ export default class extends Controller {
static targets = [ "dialog" ]
static values = {
modal: { type: Boolean, default: false },
sizing: { type: Boolean, default: true }
sizing: { type: Boolean, default: true },
autoOpen: { type: Boolean, default: false }
}
connect() {
this.dialogTarget.setAttribute("aria-hidden", "true")
if (this.autoOpenValue) this.open()
}
open() {
@@ -45,6 +45,10 @@ export default class extends Controller {
this.selectItem(target, true)
}
hoverSelect({ currentTarget }) {
this.selectItem(currentTarget)
}
selectCurrentOrReset(event) {
if (this.currentItem) {
this.#setCurrentFrom(this.currentItem)
@@ -221,6 +225,20 @@ export default class extends Controller {
})
}
// Public accessors for card_hotkeys_controller outlet
get visibleItems() {
return this.#visibleItems
}
clearSelection() {
this.#clearSelection()
this.currentItem = null
}
get hasFocus() {
return this.element.contains(document.activeElement)
}
#keyHandlers = {
ArrowDown(event) {
if (this.supportsVerticalNavigationValue) {
@@ -253,6 +271,6 @@ export default class extends Controller {
} else {
this.#clickCurrentItem(event)
}
},
}
}
}
+19 -5
View File
@@ -26,13 +26,27 @@ export default class extends Controller {
set #theme(theme) {
localStorage.setItem("theme", theme)
if (theme === "auto") {
document.documentElement.removeAttribute("data-theme")
} else {
document.documentElement.setAttribute("data-theme", theme)
const currentTheme = document.documentElement.getAttribute("data-theme") || "auto"
const hasChanged = currentTheme !== theme
const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches
const animate = hasChanged && !prefersReducedMotion
const applyTheme = () => {
if (theme === "auto") {
document.documentElement.removeAttribute("data-theme")
} else {
document.documentElement.setAttribute("data-theme", theme)
}
this.#updateButtons()
}
this.#updateButtons()
if (animate && document.startViewTransition) {
document.startViewTransition(applyTheme)
} else {
applyTheme()
}
}
#applyStoredTheme() {