From 745265fbcaa2debcc5022f4f841d12319c2c8547 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 28 Sep 2026 11:14:57 +0200 Subject: [PATCH 1/3] fix(library): a closed overview fades out once instead of flashing back The Modal reverted to open right after onClose, but the shelf closes the overview by a route change that unmounts it only later, so the dialog was drawn again for a beat. onClose may now return its promise and the Modal stays faded out until it settles. Co-Authored-By: Claude Opus 5.5 --- .../library/molecules/Modal/Modal.tsx | 24 +++++++++++++++---- .../library/molecules/Modal/Modal.types.ts | 4 +++- .../ObjectOverviewModal.tsx | 2 +- .../ObjectOverviewModal.types.ts | 2 +- .../library/organisms/Shelf/Shelf.tsx | 21 +++++++++------- 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/components/library/molecules/Modal/Modal.tsx b/src/components/library/molecules/Modal/Modal.tsx index a41e8ff6..6405b85d 100644 --- a/src/components/library/molecules/Modal/Modal.tsx +++ b/src/components/library/molecules/Modal/Modal.tsx @@ -70,9 +70,20 @@ export function Modal(props: ModalProps): JSX.Element { // Fire the real close, then drop back to the open state. If onClose // unmounts us (the usual case) this re-render is discarded; if onClose was // a guarded no-op (e.g. a confirm dialog is open on top), we revert instead - // of getting stuck faded-out-but-mounted. + // of getting stuck faded-out-but-mounted. A close that navigates (the + // object overview leaves by URL) unmounts us only once the route settles, + // so it hands back its promise and we stay faded out until then: reverting + // at once drew the dialog again for a beat before it went. + let cancelled = false; const finish = () => { - onClose(); + const pending = onClose(); + if (pending && typeof pending.then === 'function') { + const revert = () => { + if (!cancelled) setIsClosing(false); + }; + pending.then(revert, revert); + return; + } setIsClosing(false); }; @@ -85,11 +96,16 @@ export function Modal(props: ModalProps): JSX.Element { if (prefersReducedMotion) { finish(); - return; + return () => { + cancelled = true; + }; } const timer = window.setTimeout(finish, CLOSE_ANIMATION_MS); - return () => window.clearTimeout(timer); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; }, [isClosing, onClose]); const handleBackdropPointerDown = ( diff --git a/src/components/library/molecules/Modal/Modal.types.ts b/src/components/library/molecules/Modal/Modal.types.ts index 7e00589c..d7adf893 100644 --- a/src/components/library/molecules/Modal/Modal.types.ts +++ b/src/components/library/molecules/Modal/Modal.types.ts @@ -5,7 +5,9 @@ export interface ModalProps { children: ReactNode; className?: string; wrapperClassName?: string; - onClose: () => void; + // A close that finishes later (a route change) returns its promise; the + // modal stays faded out until it settles instead of flashing back. + onClose: () => void | Promise; // Modal assigns its animated-close fn here so a modal's own content buttons // (Cancel/Close/etc.) can trigger the same fade-out the backdrop and Esc use. closeRef?: MutableRefObject<(() => void) | null>; diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx index 6b1dc205..cb5cf677 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx @@ -189,7 +189,7 @@ export function ObjectOverviewModal( return; } if (deleteLoading || deleting) return; - onClose(); + return onClose(); }, [deleteLoading, deleting, onClose]); const { closeRef, close } = useModalClose(guardedOnClose); diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.types.ts b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.types.ts index 81f30adf..7daba8d4 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.types.ts +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.types.ts @@ -15,7 +15,7 @@ export interface ObjectOverviewModalProps { * Pass the *library owner*, not the viewer. */ ownerUsername: string; - onClose: () => void; + onClose: () => void | Promise; /** * Sibling objects on the same shelf — passed straight to the edit modal so * the reorder grid in step 2 can render the shelf's real contents. diff --git a/src/components/library/organisms/Shelf/Shelf.tsx b/src/components/library/organisms/Shelf/Shelf.tsx index 58652fec..0d87cffc 100644 --- a/src/components/library/organisms/Shelf/Shelf.tsx +++ b/src/components/library/organisms/Shelf/Shelf.tsx @@ -540,17 +540,22 @@ export function Shelf(props: ShelfProps): JSX.Element { { shallow: true, scroll: false }, ); }; + // Returns the navigation so the overview stays faded out until the route + // settles and unmounts it. const closeObject = () => { if (onShareRoute) { const rest = { ...router.query }; delete rest.o; - void router.push({ pathname: router.pathname, query: rest }, undefined, { - shallow: true, - scroll: false, - }); - return; + return router.push( + { pathname: router.pathname, query: rest }, + undefined, + { + shallow: true, + scroll: false, + }, + ); } - void router.push(libraryPath(ownerUsername || urlUsername), undefined, { + return router.push(libraryPath(ownerUsername || urlUsername), undefined, { shallow: true, scroll: false, }); @@ -944,7 +949,7 @@ export function Shelf(props: ShelfProps): JSX.Element { // close the overview so the user sees the move take effect. if (newShelfId != null && newShelfId !== from) { onObjectMoved?.(from, newShelfId, updated); - closeObject(); + void closeObject(); return; } // No need to track the object locally — it flows back through `objects` and @@ -954,7 +959,7 @@ export function Shelf(props: ShelfProps): JSX.Element { const handleDeleted = (id: number) => { onObjectDeleted?.(homeShelfId(id), id); - closeObject(); + void closeObject(); }; return ( From 4eebca6959aebe77ebfd3e7dca080829544a6a35 Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 28 Sep 2026 11:15:19 +0200 Subject: [PATCH 2/3] fix(library): renaming the owner of an empty library follows it to the new address A username-only save left the page on the old slug; with no library row yet the slug was the only proof of ownership, so it read as No such library. The page now moves to the new address and the account takes the new name in the same render. Co-Authored-By: Claude Opus 5.5 --- .../EditLibraryModal/EditLibraryModal.tsx | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx b/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx index 787286e1..778a8565 100644 --- a/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx +++ b/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx @@ -10,12 +10,14 @@ import { } from '@utils/library/schema/editLibrarySchema'; import axios from 'axios'; import classNames from 'classnames'; +import { useRouter } from 'next/router'; import React, { JSX, useMemo, useRef, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import type { IUpdateLibraryPayload } from '@local-types/library/library'; import type { IUpdateMeErrorBody } from '@local-types/library/user'; +import { libraryPath } from '@lib/library/libraryPath'; import { richTextLength, toEditorHtml } from '@lib/library/richText'; import { createLibrary } from '@api/library/createLibrary'; @@ -68,6 +70,7 @@ function readUsernameError( export function EditLibraryModal(props: EditLibraryModalProps): JSX.Element { const { className, library, onClose, onSaved } = props; const { accountData, setAccountData } = useAuth(); + const router = useRouter(); const currentAvatarUrl = absoluteUrl( library?.attributes.avatar?.data?.attributes.url, @@ -189,6 +192,36 @@ export function EditLibraryModal(props: EditLibraryModalProps): JSX.Element { setAvatarError(null); }; + // The library's address is the owner's username. After a rename the page + // moves to the new address, and the account takes the new name in the same + // beat the page takes it: an empty library has no row yet, so the address + // is the only thing proving it is mine, and a render where the name and the + // address disagree read as "No such library". + const followRenamedLibrary = async ( + freshUser: NonNullable, + ) => { + let swapped = false; + const swap = () => { + if (swapped) return; + swapped = true; + setAccountData(freshUser); + }; + const onLibraryPage = router.pathname.startsWith('/library/[username]'); + if (onLibraryPage && freshUser.username) { + router.events.on('beforeHistoryChange', swap); + try { + await router.replace(libraryPath(freshUser.username), undefined, { + scroll: false, + }); + } catch (error) { + console.error('EditLibraryModal could not follow the rename:', error); + } finally { + router.events.off('beforeHistoryChange', swap); + } + } + swap(); + }; + const onSubmit = async (data: EditLibraryFormData) => { if (saveInFlightRef.current) return; saveInFlightRef.current = true; @@ -284,7 +317,11 @@ export function EditLibraryModal(props: EditLibraryModalProps): JSX.Element { // reloaded by the caller via the resolved id — a direct GET by id, which // (unlike the owner relation-filter) reliably resolves a just-created row. const freshUser = await getUserInfo(); - if (freshUser) setAccountData(freshUser); + if (freshUser && usernameChanged) { + await followRenamedLibrary(freshUser); + } else if (freshUser) { + setAccountData(freshUser); + } if (libraryId != null) onSaved?.(libraryId); savedPending.current = true; close(); From f8a9d9a94bc25727824e1c296423a1cf6c5f8a1a Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Mon, 28 Sep 2026 11:15:30 +0200 Subject: [PATCH 3/3] fix(library): keep 10px between the search summary and the meter on desktop Co-Authored-By: Claude Opus 5.5 --- .../organisms/LibraryToolbar/LibraryToolbar.module.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss index 9647bece..33f54ba7 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss @@ -345,6 +345,12 @@ justify-self: end; max-width: 100%; + // On desktop the search summary hangs out of flow in the row gap, directly + // above this control. Keep 10px clear between that line and the meter. + @media (min-width: 769px) { + margin-top: 10px; + } + @media (max-width: 768px) { justify-self: stretch; }