+
))}
diff --git a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.types.ts b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.types.ts
index d7f1c773..f3b797cc 100644
--- a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.types.ts
+++ b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.types.ts
@@ -12,5 +12,8 @@ export interface ShareSelectionPanelProps {
onReorder?: (next: IObject[]) => void;
onRemove?: (id: number) => void;
onClear?: () => void;
+ // Recipient view only: open the object's overview modal on card click,
+ // mirroring how a shelf card opens it.
+ onObjectClick?: (object: IObject) => void;
className?: string;
}
diff --git a/src/components/library/organisms/Shelf/Shelf.module.scss b/src/components/library/organisms/Shelf/Shelf.module.scss
index 0dec8617..1497e489 100644
--- a/src/components/library/organisms/Shelf/Shelf.module.scss
+++ b/src/components/library/organisms/Shelf/Shelf.module.scss
@@ -2,12 +2,13 @@
display: flex;
flex-direction: column;
gap: 16px;
- padding: 16px;
+ padding: 16px 0 16px 0;
.header {
display: flex;
justify-content: space-between;
align-items: center;
+ padding: 0 16px;
.left {
display: flex;
@@ -19,11 +20,14 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
+ padding: 7px 10px;
+ border-radius: 16px;
+ background: var(--off-white);
svg {
display: block;
- width: 24px;
- height: 24px;
+ width: 13px;
+ height: 11px;
}
}
@@ -80,9 +84,57 @@
display: inline-flex;
}
+ .addWrap {
+ display: inline-flex;
+ }
+
+ .count {
+ color: var(--gray-medium);
+ font-variant-numeric: tabular-nums;
+ }
+
.button {
color: var(--brown);
}
+
+ // Mobile: split the cramped single row into two. Promote `.right`'s children
+ // into the header's flex flow (display: contents) so a zero-height full-width
+ // break can drop the two action buttons onto their own row:
+ // Row 1: settings + type icon + name (left) · count (right)
+ // Row 2: Select shelf (left) · Add object (right)
+ @media (max-width: 768px) {
+ flex-wrap: wrap;
+
+ .right {
+ display: contents;
+ }
+
+ .left {
+ order: 1;
+ min-width: 0;
+ }
+
+ .count {
+ order: 2;
+ }
+
+ &::after {
+ content: '';
+ flex-basis: 100%;
+ height: 0;
+ order: 3;
+ }
+
+ .selectShelfWrap {
+ order: 4;
+ margin-top: 12px;
+ }
+
+ .addWrap {
+ order: 5;
+ margin-top: 12px;
+ }
+ }
}
.content {
@@ -99,10 +151,45 @@
// 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;
bottom: 0;
+ right: -15px;
margin-right: -32px;
// Sit the shelf board behind the cards so each object rests fully opaque
// *on* the shelf instead of bleeding through the board's translucent top
@@ -110,6 +197,22 @@
z-index: 0;
pointer-events: none;
+ // Mobile: pull the board wider past both edges so it spans the narrow
+ // viewport. left/right set the box, so width is implied — drop the
+ // desktop fill-available width and its negative margin.
+ @media (max-width: 768px) {
+ left: -148px;
+ right: -35px;
+ width: auto;
+ margin-right: 0;
+
+ // The box is now sized by left/right (width: auto), so the inherited
+ // `auto` would shrink the board to its intrinsic width — fill instead.
+ img {
+ width: 100%;
+ }
+ }
+
img {
width: inherit;
}
@@ -136,7 +239,13 @@
// Arrows (and trackpad/touch swipe) drive navigation — hide the native
// scrollbar so it doesn't cut across the shelf artwork.
scrollbar-width: none;
- padding-left: 58px;
+ padding-left: 80px;
+ // 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 (80px), so at rest nothing is clipped.
+ clip-path: polygon(80px 0, 100% 0, 100% 100%, 80px 100%);
&::-webkit-scrollbar {
display: none;
@@ -146,6 +255,11 @@
// When the row overflows (arrows present), expose a styled horizontal
// scrollbar so the overflow is discoverable by drag, not just the arrows.
.scrollable {
+ // The 12px scrollbar consumes content-box height, which would lift the
+ // bottom-aligned cards by 12px the moment overflow appears. Shave the same
+ // 12px off padding-bottom (35px → 23px) so the cards' seating line is
+ // unchanged — the bar rides in the freed space and reads as an overlay.
+ padding-bottom: 23px;
// Must be `auto`, not `thin`: when scrollbar-width is thin/none, Chrome
// 121+ renders the standard scrollbar and IGNORES the ::-webkit-scrollbar
// rules below — so the custom 12px #c0b6ae bar never showed. `auto` keeps
@@ -163,6 +277,14 @@
background: transparent;
}
+ // Drop the platform's default increment/decrement arrow buttons at the
+ // bar's ends so only the thumb shows.
+ &::-webkit-scrollbar-button {
+ display: none;
+ width: 0;
+ height: 0;
+ }
+
&::-webkit-scrollbar-thumb {
background: #c0b6ae;
border-radius: 6px;
@@ -172,7 +294,7 @@
.cards {
display: flex;
flex-direction: row;
- gap: 35px;
+ gap: 38px;
flex-wrap: nowrap;
justify-content: flex-start;
width: max-content;
diff --git a/src/components/library/organisms/Shelf/Shelf.tsx b/src/components/library/organisms/Shelf/Shelf.tsx
index 2e1b1112..c4f6d04f 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,24 @@ export function Shelf(props: ShelfProps): JSX.Element {
const isOverflowing = canScrollLeft || canScrollRight;
+ // Advance one card per click. The stride is the distance between two adjacent
+ // slots (card width + gap); with a single card fall back to its own width, and
+ // with none to most of a viewport.
+ const scrollJump = (direction: -1 | 1) => {
+ const el = itemsRef.current;
+ if (!el) return;
+ const slots = cardsRef.current?.children;
+ let step = el.clientWidth * 0.8;
+ if (slots && slots.length > 0) {
+ const first = slots[0] as HTMLElement;
+ step =
+ slots.length > 1
+ ? (slots[1] as HTMLElement).offsetLeft - first.offsetLeft
+ : first.offsetWidth;
+ }
+ el.scrollBy({ left: direction * step, behavior: 'smooth' });
+ };
+
const closeRename = useCallback(() => {
if (renameLoading) return;
setRenameOpen(false);
@@ -233,7 +260,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 +469,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}
+ />
+
)}