fix(cmdk): bypass Alpine entirely for close + cache-bust static assets

Two layered fixes for the palette getting stuck open even after the
1.4.3 ESC fix:

1) Static asset cache busting
   - /static/css/app.css and /static/js/app.js now include ?v={app_version}
     in their <script>/<link> URLs. Container Manager Rebuild was pulling
     the new image but browsers were still using the cached JS/CSS from
     a previous version — making any fix to app.js invisible until the
     user hit Cmd+Shift+R. The version-suffixed URL forces a refetch on
     every release. _app_version is now passed in the index() context.

2) Direct DOM bypass for the palette close path
   - New cmdkForceClose() does the close via plain DOM (display:none + set
     Alpine.$data().open=false if Alpine is alive). Independent of Alpine
     reactivity timing.
   - Document-level keydown listener with capture:true installed at
     DOMContentLoaded. Runs before any focused element captures Esc /
     Cmd+K, including the palette's own input. Calls cmdkForceClose()
     directly — does not rely on the Alpine event going through.
   - X button and outer overlay onclick now invoke cmdkForceClose() via
     onclick=, not Alpine @click. The Alpine @click handlers are kept
     side-by-side as belt-and-suspenders.
   - Input keeps a literal onkeydown="" Escape handler too — three
     independent ways to close, so something always works.

Bump 1.5.0 -> 1.5.1 (patch on top of the pending 1.5.0 PR; release
will publish a single 1.5.1 tag covering both the relations editor
and the palette hardening).

Verified locally: HTML response carries ?v= on static URLs,
cmdkForceClose function is present, onkeydown handler on input is in.
151 tests + lint + types + format still green.
This commit is contained in:
authentik Default Admin 2026-05-17 23:13:19 +01:00
parent e69d6128e6
commit d983944850
4 changed files with 45 additions and 24 deletions

View file

@ -7,7 +7,7 @@ packages = ["src"]
[project]
name = "reddit-media-collector"
version = "1.5.0"
version = "1.5.1"
description = "Self-hosted media collector for Reddit with Immich integration"
readme = "README.md"
requires-python = ">=3.11"

View file

@ -98,6 +98,7 @@ async def index(request: Request):
"users": users,
"blacklist": blacklist,
"pin_enabled": pin_required(),
"app_version": _app_version,
},
)

View file

@ -1729,29 +1729,47 @@ window.removeTagRelation = removeTagRelation;
// ---- Cmd+K palette (Alpine component) ----
// Hard close: removes overlay from layout via plain DOM, bypassing Alpine.
// Used by document-level listeners and the X button so a stuck Alpine state
// can never trap the user.
function cmdkForceClose() {
const el = document.getElementById('cmdk-overlay');
if (!el) return;
el.style.display = 'none';
const data = window.Alpine && Alpine.$data ? Alpine.$data(el) : null;
if (data) data.open = false;
}
window.cmdkForceClose = cmdkForceClose;
// Document-level listener installed once at DOMContentLoaded. Uses
// `capture: true` so it runs before any focused input swallows the key.
// Independent of Alpine, so it works even if the component never mounted.
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const el = document.getElementById('cmdk-overlay');
if (el && getComputedStyle(el).display !== 'none') {
e.preventDefault();
e.stopPropagation();
cmdkForceClose();
}
} else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
const el = document.getElementById('cmdk-overlay');
const data = window.Alpine && Alpine.$data && el ? Alpine.$data(el) : null;
if (data) data.toggle();
}
}, true);
});
function cmdkPalette() {
return {
open: false,
query: '',
active: 0,
results: {authors: [], subreddits: [], tags: []},
init() {
// Window-level listener — survives x-show toggling display:none
// and runs even when the focused element is the palette's input
// (Alpine's @keydown.escape.window can race with input focus).
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.open) {
e.preventDefault();
this.close();
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
this.toggle();
}
});
},
toggle() { this.open = !this.open; if (this.open) { this.query=''; this.results={authors:[],subreddits:[],tags:[]}; this.active=0; this.$nextTick(()=>this.$el.querySelector('input')?.focus()); } },
close() { this.open = false; },
close() { this.open = false; cmdkForceClose(); },
async search() {
const q = this.query.trim();
if (!q) { this.results = {authors: [], subreddits: [], tags: []}; return; }

View file

@ -4,12 +4,12 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reddit Media Collector</title>
<link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/css/app.css?v={{ app_version }}">
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.2" defer></script>
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
<script src="/static/js/api.js"></script>
<script src="/static/js/app.js" defer></script>
<script src="/static/js/api.js?v={{ app_version }}"></script>
<script src="/static/js/app.js?v={{ app_version }}" defer></script>
</head>
<body x-data x-init="$store.prefs.init()">
<div class="header">
@ -60,12 +60,14 @@
{% include 'partials/_modal_tag.html' %}
<div id="cmdk-overlay" class="cmdk-overlay" x-data="cmdkPalette()" x-show="open" x-cloak
@click.self="close()">
<div class="cmdk-card" @click.stop>
<button type="button" class="cmdk-close" @click="close()" title="Fechar (Esc)">&times;</button>
@click.self="close()"
onclick="if(event.target===this){cmdkForceClose();}">
<div class="cmdk-card" @click.stop onclick="event.stopPropagation();">
<button type="button" class="cmdk-close" onclick="cmdkForceClose();" title="Fechar (Esc)">&times;</button>
<input type="text" x-model="query" @input.debounce.150ms="search()"
@keydown.arrow-down.prevent="move(1)" @keydown.arrow-up.prevent="move(-1)"
@keydown.enter.prevent="choose()" @keydown.escape.prevent.stop="close()"
@keydown.enter.prevent="choose()"
onkeydown="if(event.key==='Escape'){event.preventDefault();event.stopPropagation();cmdkForceClose();}"
placeholder="Buscar autores, subreddits, tags..." autocomplete="off">
<div class="cmdk-results" x-show="hasResults()">
<template x-for="(group, gi) in groups()" :key="gi">