mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-17 19:36:28 +02:00
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:
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -713,6 +713,7 @@ export default {
|
||||
removeFromProject: 'プロジェクトから削除',
|
||||
loading: 'アーカイブを読み込み中...',
|
||||
noProjectsAvailable: '利用可能なプロジェクトがありません',
|
||||
searchProjects: 'Search projects…',
|
||||
select: '選択',
|
||||
deselect: '選択解除',
|
||||
delete: '削除',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -721,6 +721,7 @@ export default {
|
||||
removeFromProject: '从项目中移除',
|
||||
loading: '加载中...',
|
||||
noProjectsAvailable: '无可用项目',
|
||||
searchProjects: 'Search projects…',
|
||||
select: '选择',
|
||||
deselect: '取消选择',
|
||||
delete: '删除',
|
||||
|
||||
@@ -721,6 +721,7 @@ export default {
|
||||
removeFromProject: '從專案中移除',
|
||||
loading: '載入中...',
|
||||
noProjectsAvailable: '無可用專案',
|
||||
searchProjects: 'Search projects…',
|
||||
select: '選擇',
|
||||
deselect: '取消選擇',
|
||||
delete: '刪除',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user