fix(archives): unbreak project-picker scroll, sort + search (#1151)

- ContextMenu's capture-phase document.scroll listener was firing on
    internal submenu scrolls too, slamming the whole menu shut on any
    wheel / arrow-key / scrollbar interaction past the 300px max-height.
    Handler now ignores scrolls whose target is inside menuRef so only
    page-level scrolls dismiss.

  - Sort projects alphabetically (localeCompare) at every project-picker
    site: Archives context-menu submenu (x2), BatchProjectModal,
    EditArchiveModal, PendingUploadsPanel, FileManagerPage. Native
    <select> sites sort once via react-query's select option; custom
    button lists sort inline.

  - Add filter-by-name search to the Archives "Add to Project" submenus
    (new submenuSearchPlaceholder prop on ContextMenuItem) and to
    BatchProjectModal. Both gated on >5 projects so small libraries
    stay uncluttered. Enter picks the first match.

  - New archives.menu.searchProjects i18n key in all 8 locales (en/de
    translated; six others seeded with English copies pending native
    translation, matching the existing flow).
This commit is contained in:
maziggy
2026-04-28 16:54:24 +02:00
parent d5153f1de3
commit 59b714e857
20 changed files with 251 additions and 113 deletions
+3
View File
File diff suppressed because one or more lines are too long
+37 -5
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { X, FolderKanban, Loader2, XCircle } from 'lucide-react';
import { X, FolderKanban, Loader2, XCircle, Search } from 'lucide-react';
import { api } from '../api/client';
import { Card, CardContent } from './Card';
import { Button } from './Button';
@@ -12,14 +13,27 @@ interface BatchProjectModalProps {
}
export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [query, setQuery] = useState('');
const { data: projects, isLoading } = useQuery({
queryKey: ['projects'],
queryFn: () => api.getProjects(),
});
const sortedProjects = useMemo(
() => (projects ? [...projects].sort((a, b) => a.name.localeCompare(b.name)) : undefined),
[projects],
);
const trimmed = query.trim().toLowerCase();
const visibleProjects = trimmed
? sortedProjects?.filter((p) => p.name.toLowerCase().includes(trimmed))
: sortedProjects;
const showSearch = (sortedProjects?.length ?? 0) > 5;
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -127,7 +141,7 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
</button>
{/* Divider */}
{projects && projects.length > 0 && (
{sortedProjects && sortedProjects.length > 0 && (
<div className="flex items-center gap-2 py-2">
<div className="flex-1 h-px bg-bambu-dark-tertiary" />
<span className="text-xs text-bambu-gray">or assign to</span>
@@ -135,8 +149,22 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
</div>
)}
{/* Search input */}
{showSearch && (
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('archives.menu.searchProjects')}
className="w-full pl-9 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray text-sm focus:border-bambu-green focus:outline-none"
/>
</div>
)}
{/* Project list */}
{projects?.map((project) => (
{visibleProjects?.map((project) => (
<button
key={project.id}
onClick={() => assignMutation.mutate(project.id)}
@@ -165,11 +193,15 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
</button>
))}
{(!projects || projects.length === 0) && (
{(!sortedProjects || sortedProjects.length === 0) && (
<p className="text-center text-bambu-gray py-4">
No projects yet. Create one from the Projects page.
</p>
)}
{sortedProjects && sortedProjects.length > 0 && visibleProjects?.length === 0 && (
<p className="text-center text-bambu-gray text-sm py-4">—</p>
)}
</div>
)}
</div>
+108 -28
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useLayoutEffect } from 'react';
import { ChevronRight } from 'lucide-react';
import { ChevronRight, Search } from 'lucide-react';
export interface ContextMenuItem {
label: string;
@@ -9,6 +9,9 @@ export interface ContextMenuItem {
disabled?: boolean;
divider?: boolean;
submenu?: ContextMenuItem[];
// When set on an item with a submenu, render a search input above the
// submenu items that filters by label (case-insensitive).
submenuSearchPlaceholder?: string;
title?: string;
}
@@ -19,6 +22,98 @@ interface ContextMenuProps {
onClose: () => void;
}
interface SubmenuPanelProps {
items: ContextMenuItem[];
searchPlaceholder?: string;
onClose: () => void;
className: string;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
function SubmenuPanel({
items,
searchPlaceholder,
onClose,
className,
onMouseEnter,
onMouseLeave,
}: SubmenuPanelProps) {
const [query, setQuery] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (searchPlaceholder) {
// Defer focus so it survives the mouse event that opened the submenu.
const id = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(id);
}
}, [searchPlaceholder]);
const trimmed = query.trim().toLowerCase();
const filteredItems = searchPlaceholder && trimmed
? items.filter((i) => i.label.toLowerCase().includes(trimmed))
: items;
return (
<div
className={className}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{searchPlaceholder && (
<div className="sticky top-0 z-[1] px-2 py-1.5 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const first = filteredItems.find((i) => !i.disabled);
if (first) {
first.onClick();
onClose();
}
}
}}
placeholder={searchPlaceholder}
className="w-full pl-7 pr-2 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
/>
</div>
</div>
)}
{filteredItems.map((subItem, subIndex) => (
<button
key={subIndex}
onClick={() => {
if (!subItem.disabled) {
subItem.onClick();
onClose();
}
}}
disabled={subItem.disabled}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
subItem.disabled
? 'text-bambu-gray cursor-not-allowed'
: subItem.danger
? 'text-red-400 hover:bg-red-400/10'
: 'text-white hover:bg-bambu-dark-tertiary'
}`}
>
{subItem.icon && <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">{subItem.icon}</span>}
<span className="flex-1 truncate">{subItem.label}</span>
</button>
))}
{searchPlaceholder && filteredItems.length === 0 && (
<div className="px-3 py-2 text-sm text-bambu-gray text-center italic">—</div>
)}
</div>
);
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [activeSubmenu, setActiveSubmenu] = useState<number | null>(null);
@@ -40,7 +135,12 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
}
};
const handleScroll = () => {
const handleScroll = (e: Event) => {
// Internal submenu scroll (overflow-y-auto on the submenu panel) must
// not dismiss the menu — only close on scroll outside our own subtree.
if (menuRef.current && menuRef.current.contains(e.target as Node)) {
return;
}
onClose();
};
@@ -179,8 +279,11 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
</button>
{/* Submenu */}
{hasSubmenu && activeSubmenu === index && (
<div
className={`absolute min-w-[160px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden max-h-[300px] overflow-y-auto z-[60] ${
<SubmenuPanel
items={item.submenu!}
searchPlaceholder={item.submenuSearchPlaceholder}
onClose={onClose}
className={`absolute min-w-[200px] bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl py-1 overflow-hidden max-h-[300px] overflow-y-auto z-[60] ${
openSubmenuLeft ? 'right-full mr-1' : 'left-full ml-1'
} ${submenuPositions[index] === 'bottom' ? 'bottom-0' : 'top-0'}`}
onMouseEnter={() => {
@@ -190,30 +293,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
}
}}
onMouseLeave={() => handleMouseLeaveSubmenu()}
>
{item.submenu!.map((subItem, subIndex) => (
<button
key={subIndex}
onClick={() => {
if (!subItem.disabled) {
subItem.onClick();
onClose();
}
}}
disabled={subItem.disabled}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${
subItem.disabled
? 'text-bambu-gray cursor-not-allowed'
: subItem.danger
? 'text-red-400 hover:bg-red-400/10'
: 'text-white hover:bg-bambu-dark-tertiary'
}`}
>
{subItem.icon && <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">{subItem.icon}</span>}
{subItem.label}
</button>
))}
</div>
/>
)}
</div>
);
@@ -66,6 +66,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: () => api.getProjects(),
select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
});
// Fetch all tags using the dedicated API
@@ -187,6 +187,7 @@ export function PendingUploadsPanel() {
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: () => api.getProjects(),
select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
});
// Archive mutation
+5 -3
View File
@@ -196,7 +196,7 @@ function FilamentAnalysisSpinner({
return () => {
dismissToast(toastId);
};
}, [elapsed, progress, sourceName, showPersistentToast, dismissToast, t, toastId]);
}, [elapsed, progress, prettyName, showPersistentToast, dismissToast, t, toastId]);
const stage = progress?.stage;
const percent = progress?.total_percent;
@@ -281,11 +281,13 @@ export function SliceModal({ source, onClose }: SliceModalProps) {
// pair; switching plates regenerates so a stale poll doesn't bleed
// progress between plates.
const previewRequestId = useMemo(() => {
const id =
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
return id;
// Tag the id with the (source, plate) so logs/Network panel show which
// pair owns the poll. Also lets the lint rule see the deps in use.
return `${source.kind}-${source.id}-p${effectivePlateId}-${random}`;
}, [source.kind, source.id, effectivePlateId]);
const filamentReqsQuery = useQuery({
queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
+1
View File
@@ -721,6 +721,7 @@ export default {
removeFromProject: 'Aus Projekt entfernen',
loading: 'Laden...',
noProjectsAvailable: 'Keine Projekte verfügbar',
searchProjects: 'Projekte suchen…',
select: 'Auswählen',
deselect: 'Abwählen',
delete: 'Löschen',
+1
View File
@@ -721,6 +721,7 @@ export default {
removeFromProject: 'Remove from Project',
loading: 'Loading...',
noProjectsAvailable: 'No projects available',
searchProjects: 'Search projects…',
select: 'Select',
deselect: 'Deselect',
delete: 'Delete',
+1
View File
@@ -714,6 +714,7 @@ export default {
removeFromProject: 'Retirer du Projet',
loading: 'Chargement...',
noProjectsAvailable: 'Aucun projet disponible',
searchProjects: 'Search projects…',
select: 'Sélectionner',
deselect: 'Désélectionner',
delete: 'Supprimer',
+1
View File
@@ -714,6 +714,7 @@ export default {
removeFromProject: 'Rimuovi dal progetto',
loading: 'Caricamento...',
noProjectsAvailable: 'Nessun progetto disponibile',
searchProjects: 'Search projects…',
select: 'Seleziona',
deselect: 'Deseleziona',
delete: 'Elimina',
+1
View File
@@ -713,6 +713,7 @@ export default {
removeFromProject: 'プロジェクトから削除',
loading: 'アーカイブを読み込み中...',
noProjectsAvailable: '利用可能なプロジェクトがありません',
searchProjects: 'Search projects…',
select: '選択',
deselect: '選択解除',
delete: '削除',
+1
View File
@@ -714,6 +714,7 @@ export default {
removeFromProject: 'Remover do projeto',
loading: 'Carregando...',
noProjectsAvailable: 'Nenhum projeto disponível',
searchProjects: 'Search projects…',
select: 'Selecionar',
deselect: 'Desmarcar',
delete: 'Excluir',
+1
View File
@@ -721,6 +721,7 @@ export default {
removeFromProject: '从项目中移除',
loading: '加载中...',
noProjectsAvailable: '无可用项目',
searchProjects: 'Search projects…',
select: '选择',
deselect: '取消选择',
delete: '删除',
+1
View File
@@ -721,6 +721,7 @@ export default {
removeFromProject: '從專案中移除',
loading: '載入中...',
noProjectsAvailable: '無可用專案',
searchProjects: 'Search projects…',
select: '選擇',
deselect: '取消選擇',
delete: '刪除',
+12 -2
View File
@@ -589,6 +589,9 @@ function ArchiveCard({
onClick: () => {},
disabled: !canModify('archives', 'update', archive.created_by_id),
title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
submenuSearchPlaceholder: (projects?.filter(p => p.status === 'active').length ?? 0) > 5
? t('archives.menu.searchProjects')
: undefined,
submenu: (() => {
const items: ContextMenuItem[] = [];
@@ -611,7 +614,9 @@ function ArchiveCard({
disabled: true,
});
} else {
const activeProjects = projects.filter(p => p.status === 'active');
const activeProjects = projects
.filter(p => p.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name));
if (activeProjects.length === 0) {
items.push({
label: t('archives.menu.noProjectsAvailable'),
@@ -1879,6 +1884,9 @@ function ArchiveListRow({
label: t('archives.menu.addToProject'),
icon: <FolderKanban className="w-4 h-4" />,
onClick: () => {},
submenuSearchPlaceholder: (projects?.filter(p => p.status === 'active').length ?? 0) > 5
? t('archives.menu.searchProjects')
: undefined,
submenu: (() => {
const items: ContextMenuItem[] = [];
if (archive.project_id) {
@@ -1896,7 +1904,9 @@ function ArchiveListRow({
disabled: true,
});
} else {
const activeProjects = projects.filter(p => p.status === 'active');
const activeProjects = projects
.filter(p => p.status === 'active')
.sort((a, b) => a.name.localeCompare(b.name));
if (activeProjects.length === 0) {
items.push({
label: t('archives.menu.noProjectsAvailable'),
+1
View File
@@ -376,6 +376,7 @@ function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderMo
const { data: projects } = useQuery({
queryKey: ['projects'],
queryFn: () => api.getProjects(),
select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
});
const { data: archives } = useQuery({
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-DRnoASko.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DrRF4CKf.css">
<script type="module" crossorigin src="/assets/index-bqAa7OcX.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
</head>
<body>
<div id="root"></div>