Browse Source

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).
maziggy 4 weeks ago
parent
commit
59b714e857

File diff suppressed because it is too large
+ 3 - 0
CHANGELOG.md


+ 37 - 5
frontend/src/components/BatchProjectModal.tsx

@@ -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 { 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 { api } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -12,14 +13,27 @@ interface BatchProjectModalProps {
 }
 }
 
 
 export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) {
 export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
+  const [query, setQuery] = useState('');
 
 
   const { data: projects, isLoading } = useQuery({
   const { data: projects, isLoading } = useQuery({
     queryKey: ['projects'],
     queryKey: ['projects'],
     queryFn: () => api.getProjects(),
     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
   // Close on Escape key
   useEffect(() => {
   useEffect(() => {
     const handleKeyDown = (e: KeyboardEvent) => {
     const handleKeyDown = (e: KeyboardEvent) => {
@@ -127,7 +141,7 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
                 </button>
                 </button>
 
 
                 {/* Divider */}
                 {/* Divider */}
-                {projects && projects.length > 0 && (
+                {sortedProjects && sortedProjects.length > 0 && (
                   <div className="flex items-center gap-2 py-2">
                   <div className="flex items-center gap-2 py-2">
                     <div className="flex-1 h-px bg-bambu-dark-tertiary" />
                     <div className="flex-1 h-px bg-bambu-dark-tertiary" />
                     <span className="text-xs text-bambu-gray">or assign to</span>
                     <span className="text-xs text-bambu-gray">or assign to</span>
@@ -135,8 +149,22 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
                   </div>
                   </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 */}
                 {/* Project list */}
-                {projects?.map((project) => (
+                {visibleProjects?.map((project) => (
                   <button
                   <button
                     key={project.id}
                     key={project.id}
                     onClick={() => assignMutation.mutate(project.id)}
                     onClick={() => assignMutation.mutate(project.id)}
@@ -165,11 +193,15 @@ export function BatchProjectModal({ selectedIds, onClose }: BatchProjectModalPro
                   </button>
                   </button>
                 ))}
                 ))}
 
 
-                {(!projects || projects.length === 0) && (
+                {(!sortedProjects || sortedProjects.length === 0) && (
                   <p className="text-center text-bambu-gray py-4">
                   <p className="text-center text-bambu-gray py-4">
                     No projects yet. Create one from the Projects page.
                     No projects yet. Create one from the Projects page.
                   </p>
                   </p>
                 )}
                 )}
+
+                {sortedProjects && sortedProjects.length > 0 && visibleProjects?.length === 0 && (
+                  <p className="text-center text-bambu-gray text-sm py-4">—</p>
+                )}
               </div>
               </div>
             )}
             )}
           </div>
           </div>

+ 108 - 28
frontend/src/components/ContextMenu.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useRef, useState, useLayoutEffect } from 'react';
 import { useEffect, useRef, useState, useLayoutEffect } from 'react';
-import { ChevronRight } from 'lucide-react';
+import { ChevronRight, Search } from 'lucide-react';
 
 
 export interface ContextMenuItem {
 export interface ContextMenuItem {
   label: string;
   label: string;
@@ -9,6 +9,9 @@ export interface ContextMenuItem {
   disabled?: boolean;
   disabled?: boolean;
   divider?: boolean;
   divider?: boolean;
   submenu?: ContextMenuItem[];
   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;
   title?: string;
 }
 }
 
 
@@ -19,6 +22,98 @@ interface ContextMenuProps {
   onClose: () => void;
   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) {
 export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
   const menuRef = useRef<HTMLDivElement>(null);
   const menuRef = useRef<HTMLDivElement>(null);
   const [activeSubmenu, setActiveSubmenu] = useState<number | null>(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();
       onClose();
     };
     };
 
 
@@ -179,8 +279,11 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
             </button>
             </button>
             {/* Submenu */}
             {/* Submenu */}
             {hasSubmenu && activeSubmenu === index && (
             {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'
                   openSubmenuLeft ? 'right-full mr-1' : 'left-full ml-1'
                 } ${submenuPositions[index] === 'bottom' ? 'bottom-0' : 'top-0'}`}
                 } ${submenuPositions[index] === 'bottom' ? 'bottom-0' : 'top-0'}`}
                 onMouseEnter={() => {
                 onMouseEnter={() => {
@@ -190,30 +293,7 @@ export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
                   }
                   }
                 }}
                 }}
                 onMouseLeave={() => handleMouseLeaveSubmenu()}
                 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>
           </div>
         );
         );

+ 1 - 0
frontend/src/components/EditArchiveModal.tsx

@@ -66,6 +66,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const { data: projects } = useQuery({
   const { data: projects } = useQuery({
     queryKey: ['projects'],
     queryKey: ['projects'],
     queryFn: () => api.getProjects(),
     queryFn: () => api.getProjects(),
+    select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
   });
   });
 
 
   // Fetch all tags using the dedicated API
   // Fetch all tags using the dedicated API

+ 1 - 0
frontend/src/components/PendingUploadsPanel.tsx

@@ -187,6 +187,7 @@ export function PendingUploadsPanel() {
   const { data: projects } = useQuery({
   const { data: projects } = useQuery({
     queryKey: ['projects'],
     queryKey: ['projects'],
     queryFn: () => api.getProjects(),
     queryFn: () => api.getProjects(),
+    select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
   });
   });
 
 
   // Archive mutation
   // Archive mutation

+ 5 - 3
frontend/src/components/SliceModal.tsx

@@ -196,7 +196,7 @@ function FilamentAnalysisSpinner({
     return () => {
     return () => {
       dismissToast(toastId);
       dismissToast(toastId);
     };
     };
-  }, [elapsed, progress, sourceName, showPersistentToast, dismissToast, t, toastId]);
+  }, [elapsed, progress, prettyName, showPersistentToast, dismissToast, t, toastId]);
 
 
   const stage = progress?.stage;
   const stage = progress?.stage;
   const percent = progress?.total_percent;
   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
   // pair; switching plates regenerates so a stale poll doesn't bleed
   // progress between plates.
   // progress between plates.
   const previewRequestId = useMemo(() => {
   const previewRequestId = useMemo(() => {
-    const id =
+    const random =
       typeof crypto !== 'undefined' && 'randomUUID' in crypto
       typeof crypto !== 'undefined' && 'randomUUID' in crypto
         ? crypto.randomUUID()
         ? crypto.randomUUID()
         : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
         : `${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]);
   }, [source.kind, source.id, effectivePlateId]);
   const filamentReqsQuery = useQuery({
   const filamentReqsQuery = useQuery({
     queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],
     queryKey: ['sliceFilamentReqs', source.kind, source.id, effectivePlateId],

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -721,6 +721,7 @@ export default {
       removeFromProject: 'Aus Projekt entfernen',
       removeFromProject: 'Aus Projekt entfernen',
       loading: 'Laden...',
       loading: 'Laden...',
       noProjectsAvailable: 'Keine Projekte verfügbar',
       noProjectsAvailable: 'Keine Projekte verfügbar',
+      searchProjects: 'Projekte suchen…',
       select: 'Auswählen',
       select: 'Auswählen',
       deselect: 'Abwählen',
       deselect: 'Abwählen',
       delete: 'Löschen',
       delete: 'Löschen',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -721,6 +721,7 @@ export default {
       removeFromProject: 'Remove from Project',
       removeFromProject: 'Remove from Project',
       loading: 'Loading...',
       loading: 'Loading...',
       noProjectsAvailable: 'No projects available',
       noProjectsAvailable: 'No projects available',
+      searchProjects: 'Search projects…',
       select: 'Select',
       select: 'Select',
       deselect: 'Deselect',
       deselect: 'Deselect',
       delete: 'Delete',
       delete: 'Delete',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -714,6 +714,7 @@ export default {
       removeFromProject: 'Retirer du Projet',
       removeFromProject: 'Retirer du Projet',
       loading: 'Chargement...',
       loading: 'Chargement...',
       noProjectsAvailable: 'Aucun projet disponible',
       noProjectsAvailable: 'Aucun projet disponible',
+      searchProjects: 'Search projects…',
       select: 'Sélectionner',
       select: 'Sélectionner',
       deselect: 'Désélectionner',
       deselect: 'Désélectionner',
       delete: 'Supprimer',
       delete: 'Supprimer',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -714,6 +714,7 @@ export default {
       removeFromProject: 'Rimuovi dal progetto',
       removeFromProject: 'Rimuovi dal progetto',
       loading: 'Caricamento...',
       loading: 'Caricamento...',
       noProjectsAvailable: 'Nessun progetto disponibile',
       noProjectsAvailable: 'Nessun progetto disponibile',
+      searchProjects: 'Search projects…',
       select: 'Seleziona',
       select: 'Seleziona',
       deselect: 'Deseleziona',
       deselect: 'Deseleziona',
       delete: 'Elimina',
       delete: 'Elimina',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -713,6 +713,7 @@ export default {
       removeFromProject: 'プロジェクトから削除',
       removeFromProject: 'プロジェクトから削除',
       loading: 'アーカイブを読み込み中...',
       loading: 'アーカイブを読み込み中...',
       noProjectsAvailable: '利用可能なプロジェクトがありません',
       noProjectsAvailable: '利用可能なプロジェクトがありません',
+      searchProjects: 'Search projects…',
       select: '選択',
       select: '選択',
       deselect: '選択解除',
       deselect: '選択解除',
       delete: '削除',
       delete: '削除',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -714,6 +714,7 @@ export default {
       removeFromProject: 'Remover do projeto',
       removeFromProject: 'Remover do projeto',
       loading: 'Carregando...',
       loading: 'Carregando...',
       noProjectsAvailable: 'Nenhum projeto disponível',
       noProjectsAvailable: 'Nenhum projeto disponível',
+      searchProjects: 'Search projects…',
       select: 'Selecionar',
       select: 'Selecionar',
       deselect: 'Desmarcar',
       deselect: 'Desmarcar',
       delete: 'Excluir',
       delete: 'Excluir',

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -721,6 +721,7 @@ export default {
       removeFromProject: '从项目中移除',
       removeFromProject: '从项目中移除',
       loading: '加载中...',
       loading: '加载中...',
       noProjectsAvailable: '无可用项目',
       noProjectsAvailable: '无可用项目',
+      searchProjects: 'Search projects…',
       select: '选择',
       select: '选择',
       deselect: '取消选择',
       deselect: '取消选择',
       delete: '删除',
       delete: '删除',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -721,6 +721,7 @@ export default {
       removeFromProject: '從專案中移除',
       removeFromProject: '從專案中移除',
       loading: '載入中...',
       loading: '載入中...',
       noProjectsAvailable: '無可用專案',
       noProjectsAvailable: '無可用專案',
+      searchProjects: 'Search projects…',
       select: '選擇',
       select: '選擇',
       deselect: '取消選擇',
       deselect: '取消選擇',
       delete: '刪除',
       delete: '刪除',

+ 12 - 2
frontend/src/pages/ArchivesPage.tsx

@@ -589,6 +589,9 @@ function ArchiveCard({
       onClick: () => {},
       onClick: () => {},
       disabled: !canModify('archives', 'update', archive.created_by_id),
       disabled: !canModify('archives', 'update', archive.created_by_id),
       title: !canModify('archives', 'update', archive.created_by_id) ? t('archives.permission.noUpdateArchives') : undefined,
       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: (() => {
       submenu: (() => {
         const items: ContextMenuItem[] = [];
         const items: ContextMenuItem[] = [];
 
 
@@ -611,7 +614,9 @@ function ArchiveCard({
             disabled: true,
             disabled: true,
           });
           });
         } else {
         } 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) {
           if (activeProjects.length === 0) {
             items.push({
             items.push({
               label: t('archives.menu.noProjectsAvailable'),
               label: t('archives.menu.noProjectsAvailable'),
@@ -1879,6 +1884,9 @@ function ArchiveListRow({
       label: t('archives.menu.addToProject'),
       label: t('archives.menu.addToProject'),
       icon: <FolderKanban className="w-4 h-4" />,
       icon: <FolderKanban className="w-4 h-4" />,
       onClick: () => {},
       onClick: () => {},
+      submenuSearchPlaceholder: (projects?.filter(p => p.status === 'active').length ?? 0) > 5
+        ? t('archives.menu.searchProjects')
+        : undefined,
       submenu: (() => {
       submenu: (() => {
         const items: ContextMenuItem[] = [];
         const items: ContextMenuItem[] = [];
         if (archive.project_id) {
         if (archive.project_id) {
@@ -1896,7 +1904,9 @@ function ArchiveListRow({
             disabled: true,
             disabled: true,
           });
           });
         } else {
         } 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) {
           if (activeProjects.length === 0) {
             items.push({
             items.push({
               label: t('archives.menu.noProjectsAvailable'),
               label: t('archives.menu.noProjectsAvailable'),

+ 1 - 0
frontend/src/pages/FileManagerPage.tsx

@@ -376,6 +376,7 @@ function LinkFolderModal({ folder, onClose, onLink, isLoading, t }: LinkFolderMo
   const { data: projects } = useQuery({
   const { data: projects } = useQuery({
     queryKey: ['projects'],
     queryKey: ['projects'],
     queryFn: () => api.getProjects(),
     queryFn: () => api.getProjects(),
+    select: (rows) => [...rows].sort((a, b) => a.name.localeCompare(b.name)),
   });
   });
 
 
   const { data: archives } = useQuery({
   const { data: archives } = useQuery({

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-7GmlJb0k.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DrRF4CKf.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-bqAa7OcX.js


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff