From 4b63c4c275fda4430da29bd59148a06ae3ab1fec Mon Sep 17 00:00:00 2001 From: MaryWylde Date: Wed, 17 Jun 2026 17:53:49 +0200 Subject: [PATCH 1/2] library: enforce 21-object-per-shelf cap in the UI Surface the backend's per-shelf object limit: catch the 400 on object create, edit (shelf change), and move, mapping the "shelf cannot have more than 21 objects" message to friendly copy. Pre-disable the Add control and show a counter once a shelf is full, with the backend remaining the source of truth. Bundles related library refinements: CharCount atom, AudioCard, ReorderGrid, AddShelfModal, and addObjectSchema field limits. Co-Authored-By: Claude Opus 4.7 --- public/library/images/icons/all.svg | 3 +- .../atoms/CharCount/CharCount.module.scss | 12 +++ .../library/atoms/CharCount/CharCount.tsx | 27 ++++++ .../library/atoms/CharCount/index.tsx | 1 + .../molecules/AddShelfModal/AddShelfModal.tsx | 6 +- .../molecules/AudioCard/AudioCard.module.scss | 8 ++ .../library/molecules/AudioCard/AudioCard.tsx | 18 +++- .../ReorderGrid/ReorderGrid.module.scss | 21 ++++- .../molecules/ReorderGrid/ReorderGrid.tsx | 72 ++++++++++++---- .../AddObjectModal/AddObjectModal.tsx | 38 ++++++++- .../ObjectOverviewModal.module.scss | 4 + .../ObjectOverviewModal.tsx | 11 ++- .../library/organisms/Shelf/Shelf.module.scss | 49 +++++++++++ .../library/organisms/Shelf/Shelf.tsx | 85 ++++++++++++++++--- src/constants/library/common.ts | 7 ++ src/lib/library/shelfFull.ts | 15 ++++ src/utils/library/schema/addObjectSchema.ts | 45 ++++++++-- 17 files changed, 371 insertions(+), 51 deletions(-) create mode 100644 src/components/library/atoms/CharCount/CharCount.module.scss create mode 100644 src/components/library/atoms/CharCount/CharCount.tsx create mode 100644 src/components/library/atoms/CharCount/index.tsx create mode 100644 src/lib/library/shelfFull.ts diff --git a/public/library/images/icons/all.svg b/public/library/images/icons/all.svg index 232482c7..6816ba9d 100644 --- a/public/library/images/icons/all.svg +++ b/public/library/images/icons/all.svg @@ -59,7 +59,8 @@ - + + diff --git a/src/components/library/atoms/CharCount/CharCount.module.scss b/src/components/library/atoms/CharCount/CharCount.module.scss new file mode 100644 index 00000000..03112796 --- /dev/null +++ b/src/components/library/atoms/CharCount/CharCount.module.scss @@ -0,0 +1,12 @@ +.count { + display: block; + margin-top: 2px; + text-align: left; + font-size: 12px; + line-height: 1.3; + color: var(--black-transparent-300); +} + +.over { + color: var(--red-600); +} diff --git a/src/components/library/atoms/CharCount/CharCount.tsx b/src/components/library/atoms/CharCount/CharCount.tsx new file mode 100644 index 00000000..3dd7359c --- /dev/null +++ b/src/components/library/atoms/CharCount/CharCount.tsx @@ -0,0 +1,27 @@ +import classNames from 'classnames'; +import React, { JSX } from 'react'; + +import styles from './CharCount.module.scss'; + +interface CharCountProps { + current: number; + max: number; + className?: string; +} + +export function CharCount({ + current, + max, + className, +}: CharCountProps): JSX.Element { + return ( + max, + })} + aria-hidden="true" + > + {current}/{max} + + ); +} diff --git a/src/components/library/atoms/CharCount/index.tsx b/src/components/library/atoms/CharCount/index.tsx new file mode 100644 index 00000000..4fd19160 --- /dev/null +++ b/src/components/library/atoms/CharCount/index.tsx @@ -0,0 +1 @@ +export * from './CharCount'; diff --git a/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx b/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx index 35fdd9a5..8cd4fc89 100644 --- a/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx +++ b/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx @@ -3,6 +3,7 @@ import React, { JSX, useState } from 'react'; import { shelfCardData } from '@constants/library/common'; +import { CharCount } from '@components/library/atoms/CharCount'; import { Loader } from '@components/library/atoms/Loader'; import { Text, TypographyVariant } from '@components/library/atoms/Text'; @@ -13,8 +14,8 @@ import type { AddShelfModalProps, ShelfType } from './AddShelfModal.types'; import styles from './AddShelfModal.module.scss'; -// Matches the single-shelf `name` constraint (`maxLength: 50`) in the backend schema. -const SHELF_NAME_MAX_LENGTH = 50; +// Mirrors the single-shelf `name` constraint in the backend schema. +const SHELF_NAME_MAX_LENGTH = 100; export function AddShelfModal(props: AddShelfModalProps): JSX.Element { const { onClose, onAddShelf, existingNames = [] } = props; @@ -78,6 +79,7 @@ export function AddShelfModal(props: AddShelfModalProps): JSX.Element { ariaLabel="Shelf name" maxLength={SHELF_NAME_MAX_LENGTH} /> + {error &&

{error}

} diff --git a/src/components/library/molecules/AudioCard/AudioCard.module.scss b/src/components/library/molecules/AudioCard/AudioCard.module.scss index 48b7476b..ce9dbdb4 100644 --- a/src/components/library/molecules/AudioCard/AudioCard.module.scss +++ b/src/components/library/molecules/AudioCard/AudioCard.module.scss @@ -90,6 +90,14 @@ width: 100%; height: 100%; object-fit: cover; + // Fade the album art in once it decodes so it no longer pops in over the + // vinyl-sleeve placeholder; `.coverImageLoaded` is added on load. + opacity: 0; + transition: opacity 0.45s ease; +} + +.coverImageLoaded { + opacity: 1; } .tags { diff --git a/src/components/library/molecules/AudioCard/AudioCard.tsx b/src/components/library/molecules/AudioCard/AudioCard.tsx index 00df7106..e53d0944 100644 --- a/src/components/library/molecules/AudioCard/AudioCard.tsx +++ b/src/components/library/molecules/AudioCard/AudioCard.tsx @@ -1,7 +1,7 @@ import { resolveStrapiUrl } from '@utils/library/resolveStrapiUrl'; import classNames from 'classnames'; import Image from 'next/image'; -import React, { JSX } from 'react'; +import React, { JSX, useCallback, useState } from 'react'; import { SelectToggle } from '@components/library/molecules/SelectToggle'; @@ -25,6 +25,16 @@ export function AudioCard({ const tags = attributes.tags?.data ?? []; const title = attributes.title; + const [coverLoaded, setCoverLoaded] = useState(false); + + // A cached image can finish decoding before React attaches `onLoad`, leaving + // it stuck at opacity 0. Catch that case via the ref's `complete` flag. + const coverRef = useCallback((node: HTMLImageElement | null) => { + if (node?.complete) { + setCoverLoaded(true); + } + }, []); + const handleActivate = () => onClick?.(object); const handleKeyDown = (e: React.KeyboardEvent) => { @@ -61,11 +71,15 @@ export function AudioCard({
{coverUrl && ( {title} setCoverLoaded(true)} /> )}
diff --git a/src/components/library/molecules/ReorderGrid/ReorderGrid.module.scss b/src/components/library/molecules/ReorderGrid/ReorderGrid.module.scss index c89b2d8c..8cbfeebd 100644 --- a/src/components/library/molecules/ReorderGrid/ReorderGrid.module.scss +++ b/src/components/library/molecules/ReorderGrid/ReorderGrid.module.scss @@ -18,7 +18,7 @@ align-items: center; gap: 6px; - &.current .card { + &.current .card:not(.placeholder) { border-color: var(--brown); box-shadow: 0 0 0 2px var(--brown); } @@ -29,7 +29,6 @@ background: var(--white); border: 1px solid var(--brown-border); border-radius: 4px; - padding: 8px; cursor: grab; user-select: none; touch-action: none; @@ -38,6 +37,24 @@ cursor: grabbing; } + // The slot left behind by the card being dragged. The solid clone rides the + // cursor in the DragOverlay; this dashed outline marks where it will land. + &.placeholder { + background: transparent; + border: 1px dashed #cebda1; + box-shadow: none; + + .cover { + opacity: 0; + } + } + + // The clone under the cursor — lift it off the grid so it reads as picked up. + &.overlayCard { + cursor: grabbing; + box-shadow: 0 8px 20px rgb(0 0 0 / 18%); + } + .cover { width: 100%; border-radius: 4px; diff --git a/src/components/library/molecules/ReorderGrid/ReorderGrid.tsx b/src/components/library/molecules/ReorderGrid/ReorderGrid.tsx index 430e031a..fbfd2d97 100644 --- a/src/components/library/molecules/ReorderGrid/ReorderGrid.tsx +++ b/src/components/library/molecules/ReorderGrid/ReorderGrid.tsx @@ -6,6 +6,8 @@ import { closestCenter, DndContext, type DragEndEvent, + DragOverlay, + type DragStartEvent, KeyboardSensor, PointerSensor, useSensor, @@ -20,7 +22,7 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import classNames from 'classnames'; -import React, { JSX } from 'react'; +import React, { JSX, useState } from 'react'; import { PlusIcon } from '@icons/library/svg'; @@ -39,6 +41,27 @@ import type { import styles from './ReorderGrid.module.scss'; +function CardCover(props: { item: ReorderItem }) { + const { item } = props; + return ( +
+ {item.coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.title} + ) : ( + + ); +} + function SortableCard(props: { item: ReorderItem; shape: ReorderItemShape; @@ -59,7 +82,6 @@ function SortableCard(props: { const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0.6 : 1, }; return ( @@ -71,25 +93,16 @@ function SortableCard(props: { })} >
-
- {item.coverUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {item.title} - ) : ( - +
{position} @@ -108,6 +121,9 @@ export function ReorderGrid(props: ReorderGridProps): JSX.Element { emptyState = 'No objects yet — add one to test reordering.', } = props; + const [activeId, setActiveId] = useState(null); + const activeItem = items.find(i => i.id === activeId) ?? null; + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor, { @@ -115,7 +131,12 @@ export function ReorderGrid(props: ReorderGridProps): JSX.Element { }), ); + const handleDragStart = (event: DragStartEvent) => { + setActiveId(String(event.active.id)); + }; + const handleDragEnd = (event: DragEndEvent) => { + setActiveId(null); const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = items.findIndex(i => i.id === active.id); @@ -134,7 +155,9 @@ export function ReorderGrid(props: ReorderGridProps): JSX.Element { setActiveId(null)} > i.id)} @@ -151,6 +174,19 @@ export function ReorderGrid(props: ReorderGridProps): JSX.Element { ))}
+ + {activeItem ? ( +
+ +
+ ) : null} +
)} diff --git a/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx b/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx index cf955533..191aacce 100644 --- a/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx +++ b/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx @@ -5,14 +5,19 @@ import { type AddObjectFormData, type BookFormData, getSchemaForType, + OBJECT_FIELD_LIMITS, } from '@utils/library/schema/addObjectSchema'; import React, { JSX, useEffect, useMemo, useRef, useState } from 'react'; import { Controller, type SubmitHandler, useForm } from 'react-hook-form'; +import { SHELF_FULL_MESSAGE } from '@constants/library/common'; + import type { IAutofillSuggestion } from '@local-types/library/autofill'; import type { IObject } from '@local-types/library/object'; import type { IShelf } from '@local-types/library/shelf'; +import { isShelfFullError } from '@lib/library/shelfFull'; + import { fetchCoverFile } from '@api/library/autofill/fetchCoverFile'; import { lookupVideoByUrl } from '@api/library/autofill/lookupVideoByUrl'; import { searchAudioSuggestions } from '@api/library/autofill/searchAudioSuggestions'; @@ -27,6 +32,7 @@ import { uploadFile } from '@api/library/upload/uploadFile'; import { ArrowIcon, SearchIcon } from '@icons/library/svg'; import { useAuth } from '@components/Context/library/AuthContext'; +import { CharCount } from '@components/library/atoms/CharCount'; import { IconName } from '@components/library/atoms/Icon'; import { Text, TypographyVariant } from '@components/library/atoms/Text'; import { @@ -182,6 +188,12 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { formState: { errors, isValid }, } = form; + // Live lengths for the character counters. `author`/`description` are optional + // on every schema, so coalesce to '' before measuring. + const titleLength = (watch('title') ?? '').length; + const authorLength = (watch('author') ?? '').length; + const descriptionLength = (watch('description') ?? '').length; + // Push a provider suggestion into the form. Values are clamped to the zod // limits so an autofill can never leave the form invalid; the cover is // best-effort — fields land first, the image follows when the proxy resolves. @@ -189,14 +201,18 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { const isBook = objectType === 'book'; const setOptions = { shouldValidate: true, shouldDirty: true } as const; - setValue('title', s.title.slice(0, isBook ? 200 : 150), setOptions); + setValue('title', s.title.slice(0, OBJECT_FIELD_LIMITS.title), setOptions); if (s.author) { - setValue('author', s.author.slice(0, isBook ? 150 : 100), setOptions); + setValue( + 'author', + s.author.slice(0, OBJECT_FIELD_LIMITS.author), + setOptions, + ); } if (s.description) { setValue( 'description', - s.description.slice(0, isBook ? 4000 : 5000), + s.description.slice(0, OBJECT_FIELD_LIMITS.description), setOptions, ); } @@ -574,6 +590,13 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { setShowSuccess(true); } catch (e) { + // Backend caps each shelf at 21 objects (all types combined) and rejects + // an over-limit create — or a move into a full shelf via the shelf + // dropdown — with a 400. Surface the dedicated full-shelf copy. + if (isShelfFullError(e)) { + setSubmitError(SHELF_FULL_MESSAGE); + return; + } // Axios failures carry a raw "Request failed with status code 500" — not // useful to a user. Show a friendly line (the title is the usual culprit) // and only fall back to a specific message when it isn't an HTTP error. @@ -627,6 +650,7 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { {...register('title')} /> )} + {errors.title && (

{errors.title.message}

)} @@ -649,6 +673,10 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { placeholderColor="#9E9E9E" {...register('author')} /> + {errors.author && (

{errors.author.message}

)} @@ -699,6 +727,10 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { rows={5} {...register('description')} /> + {errors.description && (

{errors.description.message}

)} diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss index 7c65af1b..77527a05 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss @@ -227,6 +227,10 @@ .description { color: var(--gray-darkest); line-height: 1.5; + // Preserve the line breaks the user typed — the description is plain text, so + // without this the browser collapses every newline into a single space. + white-space: pre-wrap; + overflow-wrap: anywhere; p { margin: 0 0 8px; diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx index 1a325530..0d4bfe07 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx @@ -2,6 +2,8 @@ import { resolveStrapiUrl } from '@utils/library/resolveStrapiUrl'; import classNames from 'classnames'; import React, { JSX, useCallback, useMemo, useState } from 'react'; +import { SHELF_FULL_MESSAGE } from '@constants/library/common'; + import type { Difficulty, IObject, @@ -10,6 +12,7 @@ import type { import { useClickOutside } from '@hooks/library/useClickOutside'; +import { isShelfFullError } from '@lib/library/shelfFull'; import { sanitizeHtml } from '@lib/sanitizeHtml'; import { deleteObject } from '@api/library/object/deleteObject'; @@ -263,7 +266,13 @@ export function ObjectOverviewModal( }; onUpdated?.(moved); } catch (e) { - const message = e instanceof Error ? e.message : 'Could not move object.'; + // The target shelf may already hold 21 objects — the backend rejects the + // move with a 400. Surface the dedicated full-shelf copy. + const message = isShelfFullError(e) + ? SHELF_FULL_MESSAGE + : e instanceof Error + ? e.message + : 'Could not move object.'; setMoveError(message); setMoveToShelfId(undefined); } finally { diff --git a/src/components/library/organisms/Shelf/Shelf.module.scss b/src/components/library/organisms/Shelf/Shelf.module.scss index 0dec8617..d77312c8 100644 --- a/src/components/library/organisms/Shelf/Shelf.module.scss +++ b/src/components/library/organisms/Shelf/Shelf.module.scss @@ -80,6 +80,15 @@ display: inline-flex; } + .addWrap { + display: inline-flex; + } + + .count { + color: var(--gray-medium); + font-variant-numeric: tabular-nums; + } + .button { color: var(--brown); } @@ -99,6 +108,40 @@ // seated. This is the knob: increase to slide the scrollbar up the board. padding-bottom: 65px; + // Carousel arrows, same pill as the LibraryToolbar jump arrows, overlaid on + // the shelf at each end and vertically centred on the card band. Only shown + // while the row overflows; each disables at its scroll extreme. + .arrow { + position: absolute; + top: 45%; + transform: translateY(-50%); + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; + width: 43px; + height: 43px; + padding: 0; + border-radius: 10px; + background: #fefdf9 !important; + box-shadow: 0px 4.84px 12.1px rgba(0, 0, 0, 0.15); + right: 16px; + + &:disabled { + opacity: 0.4; + cursor: default; + } + } + + .arrowLeft { + left: 16px; + right: auto; + + svg { + transform: rotate(180deg); + } + } + .banner { width: -webkit-fill-available; position: absolute; @@ -137,6 +180,12 @@ // scrollbar so it doesn't cut across the shelf artwork. scrollbar-width: none; padding-left: 58px; + // Cards scrolled off the left should disappear *into* the shelf, not float + // past its end. Clip the scroll viewport with a straight vertical left + // edge so a card sliding left is cut cleanly upright instead of on a + // diagonal (which read as a slanted/rotated cover). The first card rests + // at padding-left (58px), so at rest nothing is clipped. + clip-path: polygon(58px 0, 100% 0, 100% 100%, 58px 100%); &::-webkit-scrollbar { display: none; diff --git a/src/components/library/organisms/Shelf/Shelf.tsx b/src/components/library/organisms/Shelf/Shelf.tsx index 2e1b1112..cf78a0a1 100644 --- a/src/components/library/organisms/Shelf/Shelf.tsx +++ b/src/components/library/organisms/Shelf/Shelf.tsx @@ -10,6 +10,8 @@ import React, { useState, } from 'react'; +import { MAX_OBJECTS_PER_SHELF } from '@constants/library/common'; + import type { IObject, ObjectType } from '@local-types/library/object'; import type { ShelfVisibility } from '@local-types/library/shelf'; @@ -20,6 +22,7 @@ import { updateShelf } from '@api/library/shelf/updateShelf'; import shelfBackground from '@icons/library/images/shelfBackground.png'; import { + ArrowIcon, AudioIcon, BookIcon, PlusIcon, @@ -28,6 +31,7 @@ import { } from '@icons/library/svg'; import { useShareSelection } from '@components/Context/library/ShareSelectionContext'; +import { CharCount } from '@components/library/atoms/CharCount'; import { IconName } from '@components/library/atoms/Icon'; import { Text, TypographyVariant } from '@components/library/atoms/Text'; import { AudioCard } from '@components/library/molecules/AudioCard'; @@ -126,8 +130,8 @@ const SETTINGS_OPTIONS = [ { value: 'delete', label: 'Delete shelf' }, ]; -// Matches the single-shelf `name` constraint (`maxLength: 50`) in the backend schema. -const SHELF_NAME_MAX_LENGTH = 50; +// Mirrors the single-shelf `name` constraint in the backend schema. +const SHELF_NAME_MAX_LENGTH = 100; export function Shelf(props: ShelfProps): JSX.Element { const { @@ -159,6 +163,11 @@ export function Shelf(props: ShelfProps): JSX.Element { const typeIcon = SHELF_TYPE_ICON[shelfType] ?? ; const typeLabel = SHELF_TYPE_LABEL[shelfType] ?? 'item'; + // Backend caps a shelf at 21 objects (all types combined). Pre-disable the + // Add control once the shelf is full — the backend stays the source of truth + // (AddObjectModal still surfaces the 400), this just stops a doomed attempt. + const atObjectLimit = objects.length >= MAX_OBJECTS_PER_SHELF; + const router = useRouter(); // The opened object is addressed by the URL, not local state: the last path // segment is the object slug (see objectSlug). We match on the slug's trailing @@ -225,6 +234,13 @@ export function Shelf(props: ShelfProps): JSX.Element { const isOverflowing = canScrollLeft || canScrollRight; + // Page the row by most of a viewport, matching the LibraryToolbar jump arrows. + const scrollJump = (direction: -1 | 1) => { + const el = itemsRef.current; + if (!el) return; + el.scrollBy({ left: direction * el.clientWidth * 0.8, behavior: 'smooth' }); + }; + const closeRename = useCallback(() => { if (renameLoading) return; setRenameOpen(false); @@ -233,7 +249,10 @@ export function Shelf(props: ShelfProps): JSX.Element { const { closeRef: renameCloseRef, close: closeRenameAnimated } = useModalClose(closeRename); - const openAdd = () => setIsAddOpen(true); + const openAdd = () => { + if (atObjectLimit) return; + setIsAddOpen(true); + }; const closeAdd = () => setIsAddOpen(false); // Open/close are URL transitions, kept shallow so the library underneath is @@ -439,21 +458,57 @@ export function Shelf(props: ShelfProps): JSX.Element { )} {isOwner && ( -
+ {isOverflowing && ( + <> +