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..57e92d10
--- /dev/null
+++ b/src/components/library/atoms/CharCount/CharCount.module.scss
@@ -0,0 +1,11 @@
+.count {
+ display: block;
+ margin-top: 2px;
+ 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..f26d139f 100644
--- a/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx
+++ b/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx
@@ -1,8 +1,12 @@
import classNames from 'classnames';
import React, { JSX, useState } from 'react';
-import { shelfCardData } from '@constants/library/common';
+import {
+ SHELF_NAME_MAX_LENGTH,
+ 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,9 +17,6 @@ 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;
-
export function AddShelfModal(props: AddShelfModalProps): JSX.Element {
const { onClose, onAddShelf, existingNames = [] } = props;
const { closeRef, close } = useModalClose(onClose);
@@ -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 && (
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
+
+ ) : (
+
+ )}
+
+ );
+}
+
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
-
- ) : (
-
- )}
-
+
{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..7751bbd5 100644
--- a/src/components/library/organisms/Shelf/Shelf.tsx
+++ b/src/components/library/organisms/Shelf/Shelf.tsx
@@ -10,6 +10,11 @@ import React, {
useState,
} from 'react';
+import {
+ MAX_OBJECTS_PER_SHELF,
+ SHELF_NAME_MAX_LENGTH,
+} from '@constants/library/common';
+
import type { IObject, ObjectType } from '@local-types/library/object';
import type { ShelfVisibility } from '@local-types/library/shelf';
@@ -20,6 +25,7 @@ import { updateShelf } from '@api/library/shelf/updateShelf';
import shelfBackground from '@icons/library/images/shelfBackground.png';
import {
+ ArrowIcon,
AudioIcon,
BookIcon,
PlusIcon,
@@ -28,6 +34,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,9 +133,6 @@ 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;
-
export function Shelf(props: ShelfProps): JSX.Element {
const {
className,
@@ -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 && (
- }
- iconPosition={IconPosition.Right}
- className={styles.button}
- />
+
+ {objects.length}/{MAX_OBJECTS_PER_SHELF}
+
+ )}
+
+ {isOwner && (
+
+ }
+ iconPosition={IconPosition.Right}
+ className={styles.button}
+ disabled={atObjectLimit}
+ />
+
)}
+ {isOverflowing && (
+ <>
+
scrollJump(-1)}
+ type={ButtonType.Secondary}
+ Icon={ }
+ ariaLabel={`Scroll ${typeLabel}s left`}
+ disabled={!canScrollLeft}
+ />
+ scrollJump(1)}
+ type={ButtonType.Secondary}
+ Icon={ }
+ ariaLabel={`Scroll ${typeLabel}s right`}
+ disabled={!canScrollRight}
+ />
+ >
+ )}
}
ariaLabel={`Add ${typeLabel}`}
+ disabled={atObjectLimit}
/>
)}
@@ -590,6 +646,10 @@ export function Shelf(props: ShelfProps): JSX.Element {
ariaLabel="Shelf name"
maxLength={SHELF_NAME_MAX_LENGTH}
/>
+
{renameError && (