diff --git a/public/assets/library/library-ultrawide.png b/public/assets/library/library-ultrawide.png new file mode 100644 index 00000000..c3596860 Binary files /dev/null and b/public/assets/library/library-ultrawide.png differ diff --git a/src/api/library/getLibrariesPaginated.ts b/src/api/library/getLibrariesPaginated.ts index 58491a9e..5776ef57 100644 --- a/src/api/library/getLibrariesPaginated.ts +++ b/src/api/library/getLibrariesPaginated.ts @@ -7,13 +7,25 @@ import { LIBRARY_CARD_POPULATE } from '@api/library/libraryCardPopulate'; export const getLibrariesPaginated = async ( page = 1, pageSize = 8, + query = '', ): Promise => { try { + // Libraries are owner-scoped and shown as "'s library", so search + // the owner's username — the same relation filter `getLibraryIdByUsername` + // uses for routing. `$containsi` = case-insensitive partial match. An `$or` + // that also covered the raw `name` field silently returned no rows through + // this controller, so keep to the single proven relation filter. + const trimmed = query.trim(); + const filters = trimmed + ? { 'filters[user][username][$containsi]': trimmed } + : {}; + const { data } = await axiosInstance.get( '/api/libraries', { params: { ...LIBRARY_CARD_POPULATE, + ...filters, 'pagination[page]': page, 'pagination[pageSize]': pageSize, }, diff --git a/src/api/library/libraryCardPopulate.ts b/src/api/library/libraryCardPopulate.ts index ad8a9118..8924d0aa 100644 --- a/src/api/library/libraryCardPopulate.ts +++ b/src/api/library/libraryCardPopulate.ts @@ -1,8 +1,11 @@ // Populate the relations the home/sidebar library cards need: avatar (image), -// user (for the `/library/[username]` URL), and shelves + their objects (so the -// per-type counts reflect object totals, not shelf totals). +// user (for the `/library/[username]` URL), shelves + their objects (so the +// per-type counts reflect object totals, not shelf totals), and libraryDetails +// (the `aboutLibrary` component field shown as the card's "About" blurb — +// components aren't returned unless explicitly populated). export const LIBRARY_CARD_POPULATE = { 'populate[avatar]': true, 'populate[user]': true, 'populate[singleShelves][populate][objects]': true, + 'populate[libraryDetails]': true, } as const; diff --git a/src/assets/icons/library/svg/check-mark.svg b/src/assets/icons/library/svg/check-mark.svg new file mode 100644 index 00000000..22e1ac8c --- /dev/null +++ b/src/assets/icons/library/svg/check-mark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/icons/library/svg/index.ts b/src/assets/icons/library/svg/index.ts index cd70febe..330827f9 100644 --- a/src/assets/icons/library/svg/index.ts +++ b/src/assets/icons/library/svg/index.ts @@ -6,6 +6,7 @@ import BookIcon from './book.svg'; import BookShadowIcon from './book-shadow.svg'; import CalendarIcon from './calendar.svg'; import CheckIcon from './check.svg'; +import CheckMarkIcon from './check-mark.svg'; import ChevronUpIcon from './chevron-up.svg'; import CloseIcon from './close.svg'; import CompanyIcon from './company.svg'; @@ -36,6 +37,7 @@ export { BookShadowIcon, CalendarIcon, CheckIcon, + CheckMarkIcon, ChevronUpIcon, CloseIcon, CompanyIcon, diff --git a/src/assets/icons/library/svg/share.svg b/src/assets/icons/library/svg/share.svg index ff332303..892d4f5e 100644 --- a/src/assets/icons/library/svg/share.svg +++ b/src/assets/icons/library/svg/share.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/assets/icons/library/svg/video-shadow.svg b/src/assets/icons/library/svg/video-shadow.svg index a1720235..2e2fd28d 100644 --- a/src/assets/icons/library/svg/video-shadow.svg +++ b/src/assets/icons/library/svg/video-shadow.svg @@ -1,22 +1,30 @@ - - - + + + - - + + + + + - + + + + + + - + - + - + - + diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index c9c48145..4d7814a2 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -45,6 +45,12 @@ const Header: FC = () => { const { accountData, setAccountData } = useContext(GlobalContext); const [{ toggleSidebar }, { isDarkTheme, isOpenedSidebar }] = useGlobals(); + // Creating a library is gated by the `can-create-library` feature flag from + // GET /api/users/me (same flag the library page enforces). Drives whether the + // dropdown's "Create library" item is actionable. + const canCreateLibrary = + accountData?.featureNames?.includes('can-create-library') ?? false; + useEffect(() => { const storedToken = localStorage.getItem('accessToken'); setToken(storedToken); @@ -160,6 +166,7 @@ const Header: FC = () => { setOpenLoginModal={setOpenLogin} userImage={accountData?.picture} handleOpenSettings={handleOpenSettings} + canCreateLibrary={canCreateLibrary} hideDropdown={isOpenedSidebar} hideUsername /> @@ -239,6 +246,7 @@ const Header: FC = () => { setOpenLoginModal={setOpenLogin} userImage={accountData?.picture} handleOpenSettings={handleOpenSettings} + canCreateLibrary={canCreateLibrary} /> )} diff --git a/src/components/UserProfile/UserProfile.module.scss b/src/components/UserProfile/UserProfile.module.scss index 4fcd8f09..18e6e9c1 100644 --- a/src/components/UserProfile/UserProfile.module.scss +++ b/src/components/UserProfile/UserProfile.module.scss @@ -72,6 +72,19 @@ } } + .menuItem.disabled { + color: #9e9e9e; + cursor: not-allowed; + + .menuIcon { + opacity: 0.4; + } + + &:hover { + background-color: transparent; + } + } + .menuIcon { flex-shrink: 0; } diff --git a/src/components/UserProfile/UserProfile.tsx b/src/components/UserProfile/UserProfile.tsx index dbb9377b..807aab52 100644 --- a/src/components/UserProfile/UserProfile.tsx +++ b/src/components/UserProfile/UserProfile.tsx @@ -7,6 +7,7 @@ import Skeleton from 'react-loading-skeleton'; import { logout } from '@api/auth'; import LibraryIcon from '@icons/library/svg/library.svg'; +import PlusIcon from '@icons/library/svg/plus.svg'; import 'react-loading-skeleton/dist/skeleton.css'; import styles from './UserProfile.module.scss'; @@ -18,16 +19,28 @@ type UserProfileProps = { isDarkTheme?: boolean; hideDropdown?: boolean; hideUsername?: boolean; + canCreateLibrary?: boolean; setAccountData?: (updater: (prev: boolean) => boolean) => void; setOpenLoginModal?: (openModal: boolean) => void; handleOpenSettings?: () => void; }; const labels = { - en: { myLibrary: 'My Library', settings: 'Settings', logout: 'Log out' }, - ru: { myLibrary: 'Моя библиотека', settings: 'Настройки', logout: 'Выйти' }, + en: { + myLibrary: 'My Library', + createLibrary: 'Create library', + settings: 'Settings', + logout: 'Log out', + }, + ru: { + myLibrary: 'Моя библиотека', + createLibrary: 'Создать библиотеку', + settings: 'Настройки', + logout: 'Выйти', + }, hy: { myLibrary: 'Իմ գրադարանը', + createLibrary: 'Ստեղծել գրադարան', settings: 'Կարգավորումներ', logout: 'Դուրս գալ', }, @@ -40,6 +53,7 @@ const UserProfile: FC = ({ isDarkTheme, hideDropdown, hideUsername, + canCreateLibrary, setAccountData, setOpenLoginModal, handleOpenSettings, @@ -73,6 +87,16 @@ const UserProfile: FC = ({ router.push(`/library/${username}`); }, [router, username]); + // A library has no standalone create step — it's bootstrapped on the owner's + // own page once they add content (gated server-side by the same feature + // flag). So "Create library" just routes there; the flag drives whether the + // item is actionable at all. + const handleCreateLibrary = useCallback(() => { + if (!canCreateLibrary) return; + setIsDropdownOpen(false); + router.push(`/library/${username}`); + }, [router, username, canCreateLibrary]); + useEffect(() => { if (hideDropdown) setIsDropdownOpen(false); }, [hideDropdown]); @@ -159,6 +183,22 @@ const UserProfile: FC = ({ {t.myLibrary} )} +
+ + {t.createLibrary} +
handleNameChange(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddShelf(); + } + }} placeholder="My shelf" placeholderColor="#9E9E9E" ariaLabel="Shelf name" diff --git a/src/components/library/molecules/BookCard/BookCard.module.scss b/src/components/library/molecules/BookCard/BookCard.module.scss index c14467da..d210d6e5 100644 --- a/src/components/library/molecules/BookCard/BookCard.module.scss +++ b/src/components/library/molecules/BookCard/BookCard.module.scss @@ -63,8 +63,8 @@ // and a covered one keeps its spine and shadow. Spans the full 180×208 card. .placeholder { position: absolute; - bottom: -6px; - left: -5px; + bottom: -7px; + left: -3px; width: 180px; height: 208px; z-index: 0; diff --git a/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss index 3184b5a7..ca69e109 100644 --- a/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss +++ b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss @@ -14,25 +14,20 @@ flex-direction: column; color: #ffffff; border-radius: 4px; - // Start fully transparent with no blur, then ramp both up on activation. - // Animating the blur *value* (not just the parent's opacity) forces the - // browser to composite the backdrop progressively, so the glass fades in - // smoothly instead of popping in once the reveal settles. + // Keep the glass blur on at all times so it's there the instant the card + // reveals rather than ramping in after; only the background wash and border + // fade on activation. background: transparent; border: 1px solid rgba(255, 255, 255, 0); - backdrop-filter: blur(0); - -webkit-backdrop-filter: blur(0); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); transition: background 0.45s ease, - border-color 0.45s ease, - backdrop-filter 0.45s ease, - -webkit-backdrop-filter 0.45s ease; + border-color 0.45s ease; &.active { background: var(--info-card-bg); border-color: #ffffff; - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); } @media (max-width: 768px) { diff --git a/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts index a2ea684a..1baa35d9 100644 --- a/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts +++ b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts @@ -4,7 +4,7 @@ export interface LibraryInfoCardProps { bookCount: number; videoCount: number; songCount: number; - /** Drives the glass background/blur fade-in so it ramps instead of popping. */ + /** Fades the glass background wash and border in on reveal. */ isActive?: boolean; className?: string; } diff --git a/src/components/library/molecules/Modal/Modal.module.scss b/src/components/library/molecules/Modal/Modal.module.scss index 6a034317..d4ff31e5 100644 --- a/src/components/library/molecules/Modal/Modal.module.scss +++ b/src/components/library/molecules/Modal/Modal.module.scss @@ -39,7 +39,25 @@ } .close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; cursor: pointer; + flex-shrink: 0; + + svg { + width: 16px; + height: 16px; + + path { + fill: #aaaaaa; + } + } } } } diff --git a/src/components/library/molecules/Modal/Modal.tsx b/src/components/library/molecules/Modal/Modal.tsx index 95ff36f0..07228ad8 100644 --- a/src/components/library/molecules/Modal/Modal.tsx +++ b/src/components/library/molecules/Modal/Modal.tsx @@ -15,7 +15,6 @@ import { CloseIcon } from '@icons/library/svg'; import { Text, TypographyVariant } from '@components/library/atoms/Text'; -import { Button, ButtonSize, ButtonType } from '../Button'; import type { ModalProps } from './Modal.types'; import styles from './Modal.module.scss'; @@ -129,13 +128,14 @@ export function Modal(props: ModalProps): JSX.Element { > {title} -
)} {children} diff --git a/src/components/library/molecules/RatingBox/RatingBox.module.scss b/src/components/library/molecules/RatingBox/RatingBox.module.scss index 8fdfa471..34993efd 100644 --- a/src/components/library/molecules/RatingBox/RatingBox.module.scss +++ b/src/components/library/molecules/RatingBox/RatingBox.module.scss @@ -1,31 +1,29 @@ .wrapper { - border: 1px solid var(--brown-border); - border-radius: 12px; - padding: 16px; - background: var(--white-200); + border: 1px solid var(--beige); + border-radius: 8px; + padding: 12px; + background: var(--white); display: flex; flex-direction: column; gap: 12px; } .header { - color: var(--black-transparent-300); + color: var(--gray-medium); } .row { display: flex; + align-items: center; + justify-content: space-between; gap: 12px; - - > * { - flex: 1; - min-width: 0; - } } .field { display: flex; - flex-direction: column; - gap: 6px; + flex-direction: row; + align-items: center; + gap: 8px; position: relative; } @@ -38,10 +36,11 @@ align-items: center; justify-content: space-between; gap: 8px; - width: 100%; - padding: 10px 12px; + width: 120px; + flex-shrink: 0; + padding: 4px 12px; background: var(--white); - border: 1px solid var(--brown-border); + border: 1px solid var(--beige); border-radius: 8px; cursor: pointer; transition: border-color 0.2s ease; @@ -57,14 +56,17 @@ } .value { - font-weight: 600; + font-weight: 400; &.placeholder { color: var(--gray); - font-weight: 400; } } +.suffix { + color: var(--gray-200); +} + .chevron { flex-shrink: 0; transition: transform 0.2s ease; @@ -86,7 +88,7 @@ left: 0; right: 0; background: var(--white); - border: 1px solid var(--brown-border); + border: 1px solid var(--beige); border-radius: 8px; box-shadow: var(--dropdown-shadow); z-index: 1000; @@ -97,12 +99,12 @@ } .option { - padding: 9px 12px; + padding: 4px 12px; text-align: left; background: transparent; border: none; cursor: pointer; - font-weight: 600; + font-weight: 400; transition: background-color 0.2s ease; &:hover { diff --git a/src/components/library/molecules/RatingBox/RatingBox.tsx b/src/components/library/molecules/RatingBox/RatingBox.tsx index 84161721..87e1aa86 100644 --- a/src/components/library/molecules/RatingBox/RatingBox.tsx +++ b/src/components/library/molecules/RatingBox/RatingBox.tsx @@ -16,10 +16,10 @@ import type { RatingBoxProps } from './RatingBox.types'; import styles from './RatingBox.module.scss'; const OVERALL_COLORS: Record = { - 1: '#e4002d', + 1: '#c45222', 2: '#ff9a00', - 3: '#f5b800', - 4: '#88eebe', + 3: '#d9b800', + 4: '#2db675', 5: '#228858', }; @@ -29,10 +29,10 @@ interface DifficultyMeta { } const DIFFICULTY_META: Record = { - very_hard: { label: 'Very Hard', color: '#e4002d' }, + very_hard: { label: 'Very Hard', color: '#c45222' }, hard: { label: 'Hard', color: '#ff9a00' }, - moderate: { label: 'Moderate', color: '#f5b800' }, - easy: { label: 'Easy', color: '#228858' }, + moderate: { label: 'Moderate', color: '#d9b800' }, + easy: { label: 'Easy', color: '#2db675' }, }; const OVERALL_VALUES: OverallRating[] = [1, 2, 3, 4, 5]; @@ -52,6 +52,7 @@ interface ColoredSelectProps { onChange?: (value: T) => void; readOnly: boolean; placeholder: string; + valueSuffix?: string; } function ColoredSelect( @@ -66,6 +67,7 @@ function ColoredSelect( onChange, readOnly, placeholder, + valueSuffix, } = props; const [isOpen, setIsOpen] = useState(false); @@ -165,11 +167,14 @@ function ColoredSelect( {displayLabel} + {hasValue && valueSuffix && ( + {valueSuffix} + )} {!readOnly && ( )} @@ -198,7 +203,7 @@ export function RatingBox(props: RatingBoxProps): JSX.Element {
- label="Overall" + label="Overall:" value={overallRating} options={OVERALL_VALUES} renderLabel={v => String(v)} @@ -206,9 +211,10 @@ export function RatingBox(props: RatingBoxProps): JSX.Element { onChange={onOverallChange} readOnly={readOnly} placeholder="—" + valueSuffix="/5" /> - label="Difficulty" + label="Difficulty:" value={difficulty} options={DIFFICULTY_VALUES} renderLabel={v => DIFFICULTY_META[v].label} diff --git a/src/components/library/molecules/Tag/Tag.module.scss b/src/components/library/molecules/Tag/Tag.module.scss index 3faa5b07..62310e93 100644 --- a/src/components/library/molecules/Tag/Tag.module.scss +++ b/src/components/library/molecules/Tag/Tag.module.scss @@ -44,6 +44,10 @@ svg { width: 10px; height: 10px; + + path { + fill: var(--white); + } } } } diff --git a/src/components/library/molecules/TagMultiSelect/TagMultiSelect.module.scss b/src/components/library/molecules/TagMultiSelect/TagMultiSelect.module.scss index fa2651d9..b8eafd48 100644 --- a/src/components/library/molecules/TagMultiSelect/TagMultiSelect.module.scss +++ b/src/components/library/molecules/TagMultiSelect/TagMultiSelect.module.scss @@ -76,7 +76,7 @@ } .checkmark { - color: var(--green-200); + flex-shrink: 0; } } diff --git a/src/components/library/molecules/TagMultiSelect/TagMultiSelect.tsx b/src/components/library/molecules/TagMultiSelect/TagMultiSelect.tsx index 04aa4c24..7969576f 100644 --- a/src/components/library/molecules/TagMultiSelect/TagMultiSelect.tsx +++ b/src/components/library/molecules/TagMultiSelect/TagMultiSelect.tsx @@ -5,7 +5,7 @@ import { createPortal } from 'react-dom'; import { useAnchoredPosition } from '@hooks/library/useAnchoredPosition'; import { useClickOutside } from '@hooks/library/useClickOutside'; -import { ArrowIcon } from '@icons/library/svg'; +import { ArrowIcon, CheckMarkIcon } from '@icons/library/svg'; import { Text, TypographyVariant } from '@components/library/atoms/Text'; import { Tag } from '@components/library/molecules/Tag'; @@ -117,12 +117,11 @@ export function TagMultiSelect(props: TagMultiSelectProps): JSX.Element { > {selected && ( - - ✓ - + /> )}
); diff --git a/src/components/library/molecules/VideoCard/VideoCard.module.scss b/src/components/library/molecules/VideoCard/VideoCard.module.scss index ab817028..02d4d8e1 100644 --- a/src/components/library/molecules/VideoCard/VideoCard.module.scss +++ b/src/components/library/molecules/VideoCard/VideoCard.module.scss @@ -1,25 +1,44 @@ +// The cell that frames the object plus its drop shadow. The shadow SVG fills +// the full 292×182 box while the object (255×181) sits flush to the right +// edge, so the soft shadow only peeks out on the left and bottom — matching the +// book/audio cards where the spine/disc is exposed on the left. +.wrap { + position: relative; + width: 292px; + height: 182px; +} + +// Drop shadow behind the object, spanning the full cell. `none` on the SVG so +// it stretches edge-to-edge instead of letterboxing inside the box. +.shadow { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; +} + .card { // Without explicit border-box the 12px padding adds 24px to both dimensions. box-sizing: border-box; + position: absolute; + top: 0; + right: 0; + z-index: 1; display: flex; flex-direction: column; width: 255px; - height: 183px; + height: 181px; padding: 12px; background: var(--off-white); border-radius: 0 0 20px 20px; - box-shadow: - 0px 6px 16px 0px #0000001a, - 0px 16px 40px 0px #00000014; cursor: pointer; border: none; outline: none; &:focus-visible { - box-shadow: - 0 0 0 2px var(--brown), - 0px 6px 16px 0px #0000001a, - 0px 16px 40px 0px #00000014; + box-shadow: 0 0 0 2px var(--brown); } } @@ -106,17 +125,23 @@ } // Compact variant for the share-selection panel: a bare 231×138 thumbnail -// (no padding, no title bar, no caption). -.card.compact { +// (no shadow, no padding, no title bar, no caption). +.wrap.compact { width: 231px; height: 138px; - padding: 0; - border-radius: 0; - // Swap the floating drop shadow for the double frame (inner 1.5px cream, - // outer 1px brown) so it matches the book/audio tiles in the panel. - box-shadow: - 0 0 0 1.5px #f9f4eb, - 0 0 0 2.5px #af6a34; + + .card { + position: static; + width: 100%; + height: 100%; + padding: 0; + border-radius: 0; + // Swap the floating drop shadow for the double frame (inner 1.5px cream, + // outer 1px brown) so it matches the book/audio tiles in the panel. + box-shadow: + 0 0 0 1.5px #f9f4eb, + 0 0 0 2.5px #af6a34; + } .thumbWrap { height: 100%; diff --git a/src/components/library/molecules/VideoCard/VideoCard.tsx b/src/components/library/molecules/VideoCard/VideoCard.tsx index 15ab7c37..d0b4abfb 100644 --- a/src/components/library/molecules/VideoCard/VideoCard.tsx +++ b/src/components/library/molecules/VideoCard/VideoCard.tsx @@ -38,50 +38,53 @@ export function VideoCard({ return (
- {onSelectToggle && ( -
- -
- )} -
-
- {coverUrl ? ( - {title}} +
+ {onSelectToggle && ( +
+ - ) : ( -
- )} +
+ )} +
+
+ {coverUrl ? ( + {title} + ) : ( +
+ )} +
- -
-
+
- - {title} - + + {title} + +
); } diff --git a/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx b/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx index 191aacce..c8678db1 100644 --- a/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx +++ b/src/components/library/organisms/AddObjectModal/AddObjectModal.tsx @@ -809,7 +809,7 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { /> {objectType === 'video' && (
diff --git a/src/components/library/organisms/InteractiveCover/InteractiveCover.types.ts b/src/components/library/organisms/InteractiveCover/InteractiveCover.types.ts index 1b053f1a..5ba3d287 100644 --- a/src/components/library/organisms/InteractiveCover/InteractiveCover.types.ts +++ b/src/components/library/organisms/InteractiveCover/InteractiveCover.types.ts @@ -3,8 +3,10 @@ import type { HotspotMode } from './useHotspotTrigger'; export interface InteractiveCoverProps { /** Cover artwork rendered behind the hotspots. */ src: string; - /** Wider artwork served to 1920px+ viewports via art direction. */ + /** Wider artwork (3840x1704) served to 768–1920px viewports via . */ wideSrc?: string; + /** Panorama artwork (4000x852) served to 1920px+ viewports via . */ + ultraWideSrc?: string; alt: string; /** * How a hotspot reveals its card. Defaults to 'hover'; flip to 'click' for diff --git a/src/components/library/organisms/InteractiveCover/coverHotspots.ts b/src/components/library/organisms/InteractiveCover/coverHotspots.ts index a2390d94..21406198 100644 --- a/src/components/library/organisms/InteractiveCover/coverHotspots.ts +++ b/src/components/library/organisms/InteractiveCover/coverHotspots.ts @@ -34,18 +34,89 @@ interface HotspotGeometry { export interface CoverHotspot { id: string; - /** Geometry for the wide `library-wide.png` cover, used at 768px+. */ + /** Geometry for the wide `library-wide.png` cover, used at 768–1920px. */ wide: HotspotGeometry; + /** + * Geometry for the `library-ultrawide.png` panorama (4000x852), used at 1920px+. + * Derived from `wide` (see `toUltraWide`), with optional per-hotspot tweaks. + */ + ultraWide: HotspotGeometry; library: Omit; } +// At the 1920px breakpoint the full-bleed cover frame is 1920px wide and the +// wide art fills it; the panorama's centred 1920px crop is visually identical. +// So the panorama geometry is the wide geometry remapped from that 1920px frame +// into the fixed 4000px-wide panorama layer (centred, sides clipped) — which +// pins every glow to the same building and keeps it pinned as the frame widens. +// Vertical percentages carry over unchanged: both bands are 852px tall with the +// layer at top:0, so only the horizontal axis is rescaled and offset. +const PANORAMA_WIDTH = 4000; +const WIDE_FRAME_WIDTH = 1920; +const H_SCALE = WIDE_FRAME_WIDTH / PANORAMA_WIDTH; // 0.48 +const H_INSET = ((1 - H_SCALE) / 2) * 100; // 26 — centring inset, in % + +// Positions (left/right/width measured from a frame edge) shift and scale; +// sizes (width) only scale. +const remapPos = (value: number) => value * H_SCALE + H_INSET; +const remapSize = (value: number) => value * H_SCALE; + +const toUltraWide = ({ + hit, + highlight, + card, +}: HotspotGeometry): HotspotGeometry => ({ + hit: { + left: remapPos(hit.left), + top: hit.top, + width: remapSize(hit.width), + ...(hit.height !== undefined && { height: hit.height }), + }, + highlight: { + src: highlight.src, + alt: highlight.alt, + left: remapPos(highlight.left), + top: highlight.top, + width: remapSize(highlight.width), + }, + card: { + ...(card.left !== undefined && { left: remapPos(card.left) }), + ...(card.right !== undefined && { right: remapPos(card.right) }), + top: card.top, + }, +}); + +// Per-hotspot tweaks to the derived panorama geometry, for the spots where the +// panorama art doesn't perfectly line up with the remapped wide geometry. +// Merged field-by-field over the derived `ultraWide`, so only the values listed +// here diverge from the wide art. +interface GeometryOverride { + hit?: Partial; + highlight?: Partial; + card?: Partial<{ left: number; right: number; top: number }>; +} + +const applyOverride = ( + base: HotspotGeometry, + override?: GeometryOverride, +): HotspotGeometry => + override + ? { + hit: { ...base.hit, ...override.hit }, + highlight: { ...base.highlight, ...override.highlight }, + card: { ...base.card, ...override.card }, + } + : base; + const makeHotspot = ( id: string, wide: HotspotGeometry, library: CoverHotspot['library'], + ultraWideOverride?: GeometryOverride, ): CoverHotspot => ({ id, wide, + ultraWide: applyOverride(toUltraWide(wide), ultraWideOverride), library, }); @@ -73,6 +144,10 @@ export const coverHotspots: CoverHotspot[] = [ videoCount: 52, songCount: 17, }, + { + hit: { left: 45.8, top: 11.9, width: 6 }, + highlight: { left: 45.6, top: 7.9 }, + }, ), makeHotspot( 'house-2', @@ -94,6 +169,7 @@ export const coverHotspots: CoverHotspot[] = [ videoCount: 34, songCount: 12, }, + { hit: { left: 34.7824, top: 0 } }, ), makeHotspot( 'house-3', @@ -115,6 +191,10 @@ export const coverHotspots: CoverHotspot[] = [ videoCount: 21, songCount: 9, }, + { + hit: { left: 41, top: 52, width: 7.096, height: 34 }, + highlight: { left: 40.4, top: 17.5 }, + }, ), makeHotspot( 'house-4', @@ -136,6 +216,10 @@ export const coverHotspots: CoverHotspot[] = [ videoCount: 18, songCount: 6, }, + { + hit: { left: 35.6, top: 66 }, + highlight: { left: 35.552, top: 28 }, + }, ), makeHotspot( 'house-5', @@ -158,5 +242,6 @@ export const coverHotspots: CoverHotspot[] = [ videoCount: 14, songCount: 4, }, + { hit: { left: 57.7312, top: 44, width: 4.3008, height: 28 } }, ), ]; diff --git a/src/components/library/organisms/LibraryCard/LibraryCard.module.scss b/src/components/library/organisms/LibraryCard/LibraryCard.module.scss index a7bc3c0d..1d1fd851 100644 --- a/src/components/library/organisms/LibraryCard/LibraryCard.module.scss +++ b/src/components/library/organisms/LibraryCard/LibraryCard.module.scss @@ -36,18 +36,18 @@ } .avatar { - width: 100%; - max-width: 208px; - height: -webkit-fill-available; + width: 208px; + height: 208px; + flex-shrink: 0; @media (max-width: 590px) { width: 100px; height: 100px; - min-width: none; } } .info { + width: 100%; border: 1px solid var(--brown-border); border-radius: 8px; @@ -71,6 +71,15 @@ .text { color: var(--gray-darkest); + // Clamp the description to 3 lines with a trailing ellipsis, and + // reserve that full height (3 × 22px line-height) even for a + // one-line bio so every card's "About" block stays the same size. + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + line-height: 22px; + min-height: 66px; @media (max-width: 590px) { font-size: 14px; diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss index d9d4ab48..ae06a640 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.module.scss @@ -42,6 +42,8 @@ display: flex; flex-direction: column; gap: 8px; + flex: 1; + min-width: 0; animation: toolbar-fade 0.3s ease; } diff --git a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx index dca10fb5..6c44ff3c 100644 --- a/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx +++ b/src/components/library/organisms/LibraryToolbar/LibraryToolbar.tsx @@ -300,20 +300,33 @@ export function LibraryToolbar(props: LibraryToolbarProps): JSX.Element {
-
- - Welcome to {ownerName}’s hive - - - Discover and explore {collectionsClause}along with an incredible - playlist full of his favorite songs. - +
+
+ + Welcome to {ownerName}’s hive + + + Discover and explore {collectionsClause}along with an incredible + playlist full of his favorite songs. + +
+ + onSearchChange?.(e.target.value)} + onClear={() => onSearchChange?.('')} + wrapperClassName={styles.search} + ariaLabel="Search everywhere" + />
); diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss index 77527a05..fa3dfddf 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.module.scss @@ -13,7 +13,7 @@ align-items: center; justify-content: space-between; gap: 16px; - padding: 32px; + padding: 24px 32px; border-bottom: 1px solid var(--brown-border); flex-shrink: 0; } @@ -23,11 +23,25 @@ } .actions { + grid-column: 1 / -1; display: flex; align-items: center; + justify-content: flex-end; gap: 8px; } +// The shared Button's `default` size is a 2px-padded inline pill — too short +// here, which clipped the 16px share glyph. Give it height to match the dots +// button and stop the icon from being squeezed by the flex row. +.shareButton { + height: 36px; + padding: 0 14px; + + svg { + flex-shrink: 0; + } +} + .iconButton { display: inline-flex; align-items: center; @@ -46,6 +60,28 @@ } } +.closeButton { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + flex-shrink: 0; + + svg { + width: 16px; + height: 16px; + + path { + fill: #aaaaaa; + } + } +} + .menuWrapper { position: relative; } @@ -92,9 +128,9 @@ .body { display: grid; - grid-template-columns: 280px 1fr; + grid-template-columns: 378px 1fr; gap: 32px; - padding: 32px; + padding: 16px 32px 26px; flex: 1; min-height: 0; overflow-y: auto; @@ -133,7 +169,9 @@ color: var(--gray); &.portrait { - aspect-ratio: 2 / 3; + width: 378px; + height: 560px; + border-radius: 0; } &.landscape { @@ -169,7 +207,7 @@ } .metaLabel { - color: var(--black-transparent-300); + color: var(--gray-medium); } .metaValue { @@ -185,6 +223,7 @@ .objectTitle { color: var(--gray-darkest); + font-weight: 400; word-break: break-word; } @@ -206,7 +245,7 @@ } .rowLabel { - color: var(--black-transparent-300); + color: var(--gray-medium); } .rowValue { @@ -243,30 +282,6 @@ gap: 8px; } -.tagsHeader { - display: flex; - align-items: center; - gap: 8px; -} - -.tagsEditButton { - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border: 1px solid var(--brown-border); - border-radius: 4px; - background: var(--white); - color: var(--gray-darkest); - cursor: pointer; - transition: border-color 0.2s ease; - - &:hover { - border-color: var(--brown); - } -} - // `cursor: pointer` is scoped here — Tag elsewhere stays presentational. .tag { cursor: pointer; @@ -274,14 +289,47 @@ .destination { display: flex; + flex-direction: row; + align-items: center; flex-wrap: wrap; - gap: 16px; + gap: 8px 24px; } -.destinationCell { - display: flex; - flex-direction: column; - gap: 4px; +.destinationLine { + color: var(--gray-darkest); +} + +.destinationKey { + color: var(--gray-medium); +} + +// The Move-To control is a shared Dropdown; its internal element classes are +// hashed in another module, so we override by element with !important (mirrors +// the Shelf settings-trigger override). Compact pill, not a full-width field. +.moveToTrigger { + width: fit-content !important; + padding: 7px 12px !important; + background: var(--white) !important; + border: 1px solid var(--beige) !important; + border-radius: 8px; + box-shadow: 0px 4px 4px 0px var(--black-transparent-200); + color: var(--brown); + + p { + padding: 0 !important; + color: var(--brown); + } + + // Chevron wrapper — drop the divider/side padding the default trigger adds. + div { + border-left: none !important; + padding: 0 0 0 8px !important; + height: auto !important; + } + + svg path { + fill: #aaaaaa !important; + } } .error { @@ -318,4 +366,12 @@ max-width: 220px; margin: 0 auto; } + + // The book cover is a fixed 378×560 on desktop; drop back to a fluid, + // aspect-locked box once it's stacked above the data on small screens. + .cover.portrait { + width: 100%; + height: auto; + aspect-ratio: 2 / 3; + } } diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx index d1945e17..2ba959e3 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx @@ -39,6 +39,7 @@ import { Button, ButtonSize, ButtonType, + IconPosition, } from '@components/library/molecules/Button'; import { ConfirmationModal } from '@components/library/molecules/ConfirmationModal'; import { Dropdown } from '@components/library/molecules/Dropdown'; @@ -362,65 +363,66 @@ export function ObjectOverviewModal( {config.modalTitle} + +
+ +
+ - {menuOpen && ( -
- - -
- )} -
- +
+ + {menuOpen && ( +
+ + +
+ )} +
)} -
-
-
0 && (
-
- - Tags - - {isOwner && ( - - )} -
+ + Tags +
{tagsList.map(t => (
-
- - SHELF - - - {shelfDisplayName} - -
-
- - Position - - - {objectPosition !== undefined - ? String(objectPosition + 1) - : '—'} - -
+ + SHELF:{' '} + {shelfDisplayName} + + + Position:{' '} + {objectPosition !== undefined + ? String(objectPosition + 1) + : '—'} +
{isOwner && (
- - Move To - -
+