fix(modal,tags): five UX follow-ups

1. Hide 'archive' kind from /tags default view. Archive tags only
   appear when the user explicitly requests them via ?kind=archive.
   _get_tags_with_previews gains a default_excluded_kinds tuple that
   applies when no explicit kind filter is set.

2. Hide the 'Add' submit button on desktop (>=768px). Enter in the
   tag input (and in the fandom picker when no row is highlighted)
   already submits; the button is only needed for mobile virtual
   keyboards where Enter-to-submit is unreliable.

3. Fandom picker keyboard parity: ArrowUp/Down navigate rows, Enter
   picks a highlighted row or (if no row is highlighted) submits the
   whole add-tag form. Escape clears the highlight. Mirrors the main
   tagInput's keyboard block.

4. Tag editor merge bug: the edit modal populated nameInput.value
   with data.tag.display_name — for a character with a fandom, that
   value is 'Name (Fandom)'. Saving without stripping the suffix
   routed to a rename that never collided with the canonical bare
   name, so the merge offer never fired. Now uses data.tag.name (the
   bare form). The (Fandom) display is still shown in the fandom
   input which is rendered separately.

5. Fandom picker styling: match the main tag-form input — same
   padding, border, background, focus state. Drop the separate
   'Fandom' label; the placeholder 'Fandom (optional)' now carries
   the label role.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 08:28:18 -04:00
parent 03e89416a1
commit 3f11dcdc6c
5 changed files with 91 additions and 22 deletions
+10 -1
View File
@@ -440,7 +440,8 @@ def gallery_jump():
) )
def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None, null_fandom_only=False): def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, search=None, null_fandom_only=False,
default_excluded_kinds=('archive',)):
""" """
Helper function to get tags with counts and preview images. Helper function to get tags with counts and preview images.
Returns (tag_data list, total_count, has_more). Returns (tag_data list, total_count, has_more).
@@ -453,6 +454,11 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, sea
count_q = db.session.query(func.count(Tag.id)) count_q = db.session.query(func.count(Tag.id))
if kind: if kind:
count_q = count_q.filter(Tag.kind == kind) count_q = count_q.filter(Tag.kind == kind)
else:
# Hide noisy auto-generated kinds (archive, etc.) from the default view;
# users can still reach them via an explicit ?kind=archive filter.
if default_excluded_kinds:
count_q = count_q.filter(~Tag.kind.in_(default_excluded_kinds))
if search: if search:
count_q = count_q.filter(Tag.name.ilike(f'%{search}%')) count_q = count_q.filter(Tag.name.ilike(f'%{search}%'))
if null_fandom_only: if null_fandom_only:
@@ -467,6 +473,9 @@ def _get_tags_with_previews(kind=None, limit=35, offset=0, images_per_tag=3, sea
if kind: if kind:
q = q.filter(Tag.kind == kind) q = q.filter(Tag.kind == kind)
else:
if default_excluded_kinds:
q = q.filter(~Tag.kind.in_(default_excluded_kinds))
if search: if search:
q = q.filter(Tag.name.ilike(f'%{search}%')) q = q.filter(Tag.name.ilike(f'%{search}%'))
if null_fandom_only: if null_fandom_only:
+6 -1
View File
@@ -118,7 +118,12 @@ document.addEventListener('DOMContentLoaded', () => {
if (data.ok) { if (data.ok) {
tagIdInput.value = data.tag.id; tagIdInput.value = data.tag.id;
nameInput.value = data.tag.display_name; // Use the bare name, not display_name — display_name for a character
// includes the "(Fandom)" suffix, which would make the name input's
// value a concatenation of the bare name and the fandom string.
// Saving that as-is would skip the rename-collision merge path and
// persist a malformed tag name.
nameInput.value = data.tag.name;
kindSelect.value = data.tag.kind || 'user'; kindSelect.value = data.tag.kind || 'user';
// Set fandom fields // Set fandom fields
+47 -6
View File
@@ -879,12 +879,30 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
let fandomPickerSelectedIdx = -1;
function highlightFandomItem(idx) {
if (!fandomPickerAutocomplete) return;
const items = fandomPickerAutocomplete.querySelectorAll('.tag-autocomplete-item');
items.forEach((el, i) => el.classList.toggle('selected', i === idx));
fandomPickerSelectedIdx = idx;
}
function pickFandomRow(row) {
if (!row || !fandomPickerInput) return;
fandomPickerInput.value = row.dataset.fandomName;
fandomPickerInput.dataset.fandomId = row.dataset.fandomId;
clearFandomAutocomplete();
fandomPickerSelectedIdx = -1;
}
if (fandomPickerInput) { if (fandomPickerInput) {
fandomPickerInput.addEventListener('input', () => { fandomPickerInput.addEventListener('input', () => {
clearTimeout(fandomPickerDebounce); clearTimeout(fandomPickerDebounce);
const term = fandomPickerInput.value.trim(); const term = fandomPickerInput.value.trim();
// Any typing invalidates a previously-selected fandom id. // Any typing invalidates a previously-selected fandom id + highlight.
delete fandomPickerInput.dataset.fandomId; delete fandomPickerInput.dataset.fandomId;
fandomPickerSelectedIdx = -1;
if (!term) { if (!term) {
clearFandomAutocomplete(); clearFandomAutocomplete();
return; return;
@@ -896,6 +914,33 @@ document.addEventListener('DOMContentLoaded', () => {
.catch(() => clearFandomAutocomplete()); .catch(() => clearFandomAutocomplete());
}, 150); }, 150);
}); });
// Keyboard parity with the main tagInput: ArrowDown/Up to navigate rows,
// Enter to pick a highlighted row (or submit the whole form when no row
// is highlighted), Escape to clear the highlight.
fandomPickerInput.addEventListener('keydown', (e) => {
const rows = fandomPickerAutocomplete
? fandomPickerAutocomplete.querySelectorAll('.tag-autocomplete-item')
: [];
const count = rows.length;
if (e.key === 'ArrowDown' && count > 0) {
e.preventDefault();
highlightFandomItem(Math.min(fandomPickerSelectedIdx + 1, count - 1));
} else if (e.key === 'ArrowUp' && count > 0) {
e.preventDefault();
highlightFandomItem(Math.max(fandomPickerSelectedIdx - 1, 0));
} else if (e.key === 'Enter') {
if (fandomPickerSelectedIdx >= 0 && rows[fandomPickerSelectedIdx]) {
e.preventDefault();
pickFandomRow(rows[fandomPickerSelectedIdx]);
} else {
e.preventDefault();
submitTag();
}
} else if (e.key === 'Escape') {
highlightFandomItem(-1);
}
});
} }
if (fandomPickerAutocomplete) { if (fandomPickerAutocomplete) {
@@ -903,11 +948,7 @@ document.addEventListener('DOMContentLoaded', () => {
const row = e.target.closest('[data-fandom-id]'); const row = e.target.closest('[data-fandom-id]');
if (!row) return; if (!row) return;
e.preventDefault(); // keep focus on the input e.preventDefault(); // keep focus on the input
if (fandomPickerInput) { pickFandomRow(row);
fandomPickerInput.value = row.dataset.fandomName;
fandomPickerInput.dataset.fandomId = row.dataset.fandomId;
}
clearFandomAutocomplete();
}); });
} }
+27 -12
View File
@@ -3046,24 +3046,39 @@ body.select-mode .gallery-infinite-container {
opacity: 1; opacity: 1;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.fandom-picker-label {
display: block;
font-size: 0.85rem;
color: var(--muted, #888);
margin-bottom: 0.25rem;
}
.fandom-picker-input-row { .fandom-picker-input-row {
display: flex; display: flex;
gap: 0.25rem; gap: 0.5rem;
align-items: center; align-items: center;
} }
/* Match the main .tag-form input styling so the picker feels like the
same control as the add-tag field above it. */
.fandom-picker-input { .fandom-picker-input {
flex: 1; flex: 1;
padding: 0.4rem 0.6rem; padding: 0.5rem 0.75rem;
background: var(--input-bg, #111); border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid var(--input-border, #333); border-radius: 6px;
border-radius: 0.25rem; background: rgba(255, 255, 255, 0.08);
color: var(--fg, #eee); color: var(--text);
font-size: 16px;
}
.fandom-picker-input::placeholder {
color: var(--text-muted);
}
.fandom-picker-input:focus {
outline: none;
border-color: var(--btn-primary);
background: rgba(255, 255, 255, 0.12);
}
/* Desktop: the form's submit button is redundant — Enter on the tag input
(and Enter in the fandom picker when no row is highlighted) already
submits. Keep the button visible on mobile where virtual keyboards make
Enter-to-submit less reliable. */
@media (min-width: 768px) {
.tag-form > button[type="submit"] {
display: none;
}
} }
/* Ambiguous-candidate picker (Task 24) */ /* Ambiguous-candidate picker (Task 24) */
+1 -2
View File
@@ -62,10 +62,9 @@
<button type="submit">Add</button> <button type="submit">Add</button>
</form> </form>
<div id="fandomPickerWrapper" class="fandom-picker-wrapper" aria-hidden="true"> <div id="fandomPickerWrapper" class="fandom-picker-wrapper" aria-hidden="true">
<label class="fandom-picker-label" for="fandomPickerInput">Fandom</label>
<div class="fandom-picker-input-row"> <div class="fandom-picker-input-row">
<input type="text" id="fandomPickerInput" class="fandom-picker-input" <input type="text" id="fandomPickerInput" class="fandom-picker-input"
placeholder="Pick or create fandom (optional)" autocomplete="off"> placeholder="Fandom (optional)" autocomplete="off">
<button type="button" id="fandomPickerClear" class="btn-small" <button type="button" id="fandomPickerClear" class="btn-small"
aria-label="Clear fandom" title="Clear fandom">×</button> aria-label="Clear fandom" title="Clear fandom">×</button>
</div> </div>