diff --git a/.gitignore b/.gitignore index addfb8f1..2fa4c450 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ # Local-only — never commit. /attachments/ +# Pasted/dropped image scratch — local-only, never commit. +/dropped-images/ + # Logs logs *.log 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/public/assets/library/library-wide.png b/public/assets/library/library-wide.png new file mode 100644 index 00000000..234acf12 Binary files /dev/null and b/public/assets/library/library-wide.png differ diff --git a/public/assets/library/library.png b/public/assets/library/library.png new file mode 100644 index 00000000..cb1fb32a Binary files /dev/null and b/public/assets/library/library.png differ diff --git a/public/library/images/hotspots/house-1.svg b/public/library/images/hotspots/house-1.svg new file mode 100644 index 00000000..d9d10001 --- /dev/null +++ b/public/library/images/hotspots/house-1.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/hotspots/house-2.svg b/public/library/images/hotspots/house-2.svg new file mode 100644 index 00000000..5631cb18 --- /dev/null +++ b/public/library/images/hotspots/house-2.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/hotspots/house-3.svg b/public/library/images/hotspots/house-3.svg new file mode 100644 index 00000000..a13d5d47 --- /dev/null +++ b/public/library/images/hotspots/house-3.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/hotspots/house-4.svg b/public/library/images/hotspots/house-4.svg new file mode 100644 index 00000000..7a426df4 --- /dev/null +++ b/public/library/images/hotspots/house-4.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/hotspots/house-5.svg b/public/library/images/hotspots/house-5.svg new file mode 100644 index 00000000..d56bd049 --- /dev/null +++ b/public/library/images/hotspots/house-5.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/library/images/icons/all.svg b/public/library/images/icons/all.svg index cb1ce8a0..6816ba9d 100644 --- a/public/library/images/icons/all.svg +++ b/public/library/images/icons/all.svg @@ -1,91 +1,92 @@ - + 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.module.scss b/src/components/Header/Header.module.scss index cbdf8332..7996e529 100644 --- a/src/components/Header/Header.module.scss +++ b/src/components/Header/Header.module.scss @@ -224,11 +224,13 @@ justify-content: center; z-index: 113; /* Desktop sets gap: 74px which pushes children past the viewport - on phones. Reset for mobile and clip any residual overflow. */ + on phones. Reset for mobile. Don't add overflow-x: hidden here — it + forces overflow-y to auto, turning the fixed header into a scroll + container that clips the user-profile dropdown (which opens below it). + The root already clips residual horizontal overflow. */ gap: 0; max-width: 100vw; max-width: 100dvw; - overflow-x: hidden; .actions { display: block; 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/ScrollToTop/ScrollToTop.tsx b/src/components/ScrollToTop/ScrollToTop.tsx index 8e304049..52576dbd 100644 --- a/src/components/ScrollToTop/ScrollToTop.tsx +++ b/src/components/ScrollToTop/ScrollToTop.tsx @@ -8,10 +8,17 @@ import ArrowUp from '@icons/ArrowUp'; import styles from './ScrollToTop.module.scss'; const SCROLL_THRESHOLD = 300; +/* Gap between the "to top" button and the Copilot pill that sits in the + bottom-right corner (the widget is a separate DOM root injected by the + concierge bundle). */ +const COPILOT_GAP = 16; const ScrollToTop: FC = () => { const [{}, { isDarkTheme }] = useGlobals(); const [isVisible, setIsVisible] = useState(false); + /* Right offset so the button parks just LEFT of the Copilot pill. null + until measured — then the inline style overrides the SCSS default. */ + const [rightOffset, setRightOffset] = useState(null); const handleScroll = useCallback(() => { setIsVisible(window.scrollY > SCROLL_THRESHOLD); @@ -22,6 +29,43 @@ const ScrollToTop: FC = () => { return () => window.removeEventListener('scroll', handleScroll); }, [handleScroll]); + /* Measure the Copilot pill and sit to its left. The pill width varies + by locale (RU label is wider) and open/closed state, so we measure + live rather than hardcode. The widget bundle loads async, so retry + shortly and watch the pill for size changes. */ + useEffect(() => { + const compute = () => { + const el = document.querySelector( + '.ks-aux-pill, .ks-aux-root', + ) as HTMLElement | null; + if (!el) { + setRightOffset(null); + return; + } + const rect = el.getBoundingClientRect(); + if (rect.width === 0) { + setRightOffset(null); + return; + } + setRightOffset(window.innerWidth - rect.left + COPILOT_GAP); + }; + + compute(); + window.addEventListener('resize', compute); + const retry = window.setTimeout(compute, 1200); + let ro: ResizeObserver | null = null; + const pill = document.querySelector('.ks-aux-pill'); + if (pill && typeof ResizeObserver !== 'undefined') { + ro = new ResizeObserver(compute); + ro.observe(pill); + } + return () => { + window.removeEventListener('resize', compute); + window.clearTimeout(retry); + ro?.disconnect(); + }; + }, [isVisible]); + const handleClick = useCallback(() => { window.scrollTo({ top: 0, behavior: 'smooth' }); }, []); @@ -33,6 +77,7 @@ const ScrollToTop: FC = () => { className={cn(styles.scrollToTop, { [styles.dark]: isDarkTheme, })} + style={rightOffset != null ? { right: `${rightOffset}px` } : undefined} onClick={handleClick} aria-label="Scroll to top" title="Scroll to top" diff --git a/src/components/UserProfile/UserProfile.module.scss b/src/components/UserProfile/UserProfile.module.scss index 4fcd8f09..050eb7cf 100644 --- a/src/components/UserProfile/UserProfile.module.scss +++ b/src/components/UserProfile/UserProfile.module.scss @@ -26,7 +26,7 @@ .dropdown { position: absolute; top: calc(100% + 8px); - right: 24px; + right: 0; min-width: 180px; background-image: url('/keepsimple_/assets/user-dropdown/white-bg.png'); background-size: cover; @@ -36,6 +36,10 @@ z-index: 200; display: flex; flex-direction: column; + // The home hero {t.myLibrary} )} +
+ + {t.createLibrary} +
{url ? ( - Picture of the author + Picture of the author ) : ( )} 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/atoms/Icon/Icon.tsx b/src/components/library/atoms/Icon/Icon.tsx index d7f2d1c5..44f4d821 100644 --- a/src/components/library/atoms/Icon/Icon.tsx +++ b/src/components/library/atoms/Icon/Icon.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; import type { JSX } from 'react'; +import { useEffect } from 'react'; import { IconName, IconProps } from './Icon.types'; @@ -29,6 +30,41 @@ const ICON_VIEWBOX: Record = { [IconName.Copy]: '0 0 16 16', }; +const SPRITE_URL = '/library/images/icons/all.svg'; +const SPRITE_DOM_ID = 'library-icon-sprite'; + +let spritePromise: Promise | null = null; + +// Safari (and some WebViews) ignore external ``, so the +// sprite never resolves and every icon renders blank there. Fetch the sprite +// once and inline it into the document, then reference symbols same-document via +// ``, which every browser supports. +function ensureSprite() { + if (typeof document === 'undefined' || spritePromise) { + return; + } + if (document.getElementById(SPRITE_DOM_ID)) { + return; + } + spritePromise = fetch(SPRITE_URL) + .then(res => res.text()) + .then(markup => { + if (document.getElementById(SPRITE_DOM_ID)) { + return; + } + const container = document.createElement('div'); + container.id = SPRITE_DOM_ID; + container.setAttribute('aria-hidden', 'true'); + container.style.cssText = + 'position:absolute;width:0;height:0;overflow:hidden'; + container.innerHTML = markup; + document.body.prepend(container); + }) + .catch(() => { + spritePromise = null; + }); +} + export function Icon(props: IconProps): JSX.Element { const { width = 40, @@ -38,6 +74,10 @@ export function Icon(props: IconProps): JSX.Element { name, } = props; + useEffect(() => { + ensureSprite(); + }, []); + return ( - + ); } diff --git a/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx b/src/components/library/molecules/AddShelfModal/AddShelfModal.tsx index 35fdd9a5..ad4c0769 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); @@ -73,11 +74,18 @@ export function AddShelfModal(props: AddShelfModalProps): JSX.Element { type="text" value={name} onChange={e => handleNameChange(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddShelf(); + } + }} placeholder="My shelf" placeholderColor="#9E9E9E" 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/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 new file mode 100644 index 00000000..ca69e109 --- /dev/null +++ b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.module.scss @@ -0,0 +1,103 @@ +.card { + // Translucent green wash + blur so the cover art reads through the card, + // matching the Figma "glass" panel. Exposed as a variable so it can be + // dropped to fully transparent without touching the rest of the styles. + --info-card-bg: rgba(32, 54, 44, 0.35); + + width: 392px; + // Design height, but let real (longer) library copy grow the card instead + // of overflowing the fixed 314px box. + min-height: 314px; + max-width: 100%; + padding: 20px 16px; + display: flex; + flex-direction: column; + color: #ffffff; + border-radius: 4px; + // 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(8px); + -webkit-backdrop-filter: blur(8px); + transition: + background 0.45s ease, + border-color 0.45s ease; + + &.active { + background: var(--info-card-bg); + border-color: #ffffff; + } + + @media (max-width: 768px) { + width: 100%; + min-height: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .card { + transition: + background 0.2s ease, + border-color 0.2s ease; + } +} + +.heading { + margin: 0; + font-family: var(--font-source-serif); + font-weight: 700; + font-size: 24px; + line-height: 1.25; + color: #ffffff; +} + +.divider { + display: block; + height: 1px; + width: 100%; + margin: 16px 0; + background: rgba(255, 255, 255, 0.7); +} + +.section + .section { + margin-top: 20px; +} + +.label { + font-weight: 700; + font-size: 16px; + line-height: 1.4; + color: #ffffff; +} + +.about { + margin-top: 8px; + font-weight: 400; + font-size: 16px; + line-height: 1.5; + color: #ffffff; +} + +.objects { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 20px; +} + +.object { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.count { + font-weight: 400; + font-size: 16px; + line-height: 1; + color: #ffffff; + white-space: nowrap; +} diff --git a/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.tsx b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.tsx new file mode 100644 index 00000000..5115d2d3 --- /dev/null +++ b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.tsx @@ -0,0 +1,73 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { Icon, IconName } from '@components/library/atoms/Icon'; +import { + TagType, + Text, + TypographyVariant, +} from '@components/library/atoms/Text'; + +import type { LibraryInfoCardProps } from './LibraryInfoCard.types'; + +import styles from './LibraryInfoCard.module.scss'; + +export function LibraryInfoCard({ + libraryName, + about, + bookCount, + videoCount, + songCount, + isActive, + className, +}: LibraryInfoCardProps) { + const objects: { name: IconName; count: number; label: string }[] = [ + { name: IconName.Book, count: bookCount, label: 'Books' }, + { name: IconName.Video, count: videoCount, label: 'Videos' }, + { name: IconName.Audio, count: songCount, label: 'Music' }, + ]; + + return ( +
+ + {libraryName} + + + + +
+ + About + + + {about} + +
+ +
+ + Objects + +
+ {objects.map(({ name, count, label }) => ( + + + + {count} {label} + + + ))} +
+
+
+ ); +} diff --git a/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts new file mode 100644 index 00000000..1baa35d9 --- /dev/null +++ b/src/components/library/molecules/LibraryInfoCard/LibraryInfoCard.types.ts @@ -0,0 +1,10 @@ +export interface LibraryInfoCardProps { + libraryName: string; + about: string; + bookCount: number; + videoCount: number; + songCount: number; + /** Fades the glass background wash and border in on reveal. */ + isActive?: boolean; + className?: string; +} diff --git a/src/components/library/molecules/LibraryInfoCard/index.tsx b/src/components/library/molecules/LibraryInfoCard/index.tsx new file mode 100644 index 00000000..9364ec99 --- /dev/null +++ b/src/components/library/molecules/LibraryInfoCard/index.tsx @@ -0,0 +1,2 @@ +export * from './LibraryInfoCard'; +export * from './LibraryInfoCard.types'; 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/Object/Object.module.scss b/src/components/library/molecules/Object/Object.module.scss index 40e041ff..08d5f483 100644 --- a/src/components/library/molecules/Object/Object.module.scss +++ b/src/components/library/molecules/Object/Object.module.scss @@ -8,6 +8,9 @@ .icon { display: flex; + // Icons are currentColor sprites; the cover card recolors them white. Here, + // on light cards/sidebar, pin a dark tone so they don't inherit white. + color: var(--gray-darkest); background: transparent; border-radius: 8px 0 0 8px; padding: 3px 4px 3px 0; 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 5fd20837..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); @@ -74,9 +76,13 @@ function ColoredSelect( const triggerRef = useRef(null); const menuRef = useRef(null); - // Keep the portaled menu glued to the trigger as the modal/page scrolls, and - // flip it above the trigger when it would overflow the bottom of the viewport. - const menuPos = useAnchoredPosition(triggerRef, isOpen, menuRef); + // Keep the portaled menu glued to the trigger as the modal/page scrolls. + // Placement is decided by viewport width, not measured space: open upward at + // 1920px and below, downward only on wider screens. Width-based placement is + // settled before the menu paints, so it never opens one way then jumps. + const menuPos = useAnchoredPosition(triggerRef, isOpen, menuRef, { + openUpMaxWidth: 1920, + }); const handleToggle = () => { if (readOnly) return; @@ -161,11 +167,14 @@ function ColoredSelect( {displayLabel} + {hasValue && valueSuffix && ( + {valueSuffix} + )} {!readOnly && ( )} @@ -194,7 +203,7 @@ export function RatingBox(props: RatingBoxProps): JSX.Element {
- label="Overall" + label="Overall:" value={overallRating} options={OVERALL_VALUES} renderLabel={v => String(v)} @@ -202,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/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/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 cf955533..c8678db1 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}

)} @@ -777,7 +809,7 @@ export function AddObjectModal(props: AddObjectModalProps): JSX.Element { /> {objectType === 'video' && ( +
+ +
+ - {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 - void; +}): JSX.Element { switch (object.attributes.type) { case 'video': - return ; + return ; case 'audio': - return ; + return ; default: - return ; + return ; } } @@ -60,8 +66,9 @@ function SortableItem(props: { position: number; readOnly: boolean; onRemove?: (id: number) => void; + onObjectClick?: (object: IObject) => void; }): JSX.Element { - const { object, position, readOnly, onRemove } = props; + const { object, position, readOnly, onRemove, onObjectClick } = props; const { attributes, listeners, @@ -100,15 +107,19 @@ function SortableItem(props: { )} - {/* Drag handle wraps the card; the card carries no onClick here, so a - pointer-press always starts a drag instead of opening the overview. */} + {/* Owner view: the handle wraps the card and the card carries no onClick, + so a pointer-press starts a drag instead of opening the overview. + Recipient view: no drag, so the card click opens the overview modal. */}
- +
{!readOnly && sequence} @@ -124,6 +135,7 @@ export function ShareSelectionPanel({ onReorder, onRemove, onClear, + onObjectClick, className, }: ShareSelectionPanelProps): JSX.Element | null { const [collapsed, setCollapsed] = useState(false); @@ -197,7 +209,11 @@ export function ShareSelectionPanel({ })} aria-label={readOnly ? 'Shared selection' : 'Share selection'} > -
+
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 && ( -
+ {isOverflowing && ( + <> +
@@ -183,14 +215,22 @@ export function HomeTemplate({ data: dataOverride }: HomeTemplateProps) { Loading libraries… + ) : currentLibraries.length === 0 ? ( + + {debouncedQuery + ? `No libraries match “${debouncedQuery}”.` + : 'No libraries yet.'} + ) : ( renderLibraryCards )}
-
- -
+ {totalPages > 1 && ( +
+ +
+ )}
diff --git a/src/layouts/library/Library/Library.module.scss b/src/layouts/library/Library/Library.module.scss index ed299dd3..d53f7abe 100644 --- a/src/layouts/library/Library/Library.module.scss +++ b/src/layouts/library/Library/Library.module.scss @@ -7,6 +7,12 @@ min-height: calc(100vh - 168px); } + // Mobile: drop the top padding so the sticky toolbar's divider sits flush + // against the fixed header instead of floating below a 16px gap. + @media (max-width: 768px) { + padding-top: 0; + } + // Fill the viewport below the 48px sticky header so the content centers in the // real available space rather than the top 60vh (which sat it too high). .empty { diff --git a/src/lib/library/shelfFull.ts b/src/lib/library/shelfFull.ts new file mode 100644 index 00000000..a49301ee --- /dev/null +++ b/src/lib/library/shelfFull.ts @@ -0,0 +1,15 @@ +// The shelf-object cap has no dedicated error code — the backend only rejects +// an over-limit create/move with a 400 carrying the message +// "A shelf cannot have more than 21 objects". Match on that message (number +// kept flexible so a backend bump doesn't silently stop matching) so the UI +// can swap it for SHELF_FULL_MESSAGE. +const SHELF_FULL_PATTERN = /shelf cannot have more than \d+ objects/i; + +export function isShelfFullError(err: unknown): boolean { + const response = (err as { response?: { status?: number; data?: unknown } }) + ?.response; + if (response?.status !== 400) return false; + const message = (response.data as { error?: { message?: string } })?.error + ?.message; + return typeof message === 'string' && SHELF_FULL_PATTERN.test(message); +} diff --git a/src/lib/settings-helpers.ts b/src/lib/settings-helpers.ts index 2f222e70..f7bb8255 100644 --- a/src/lib/settings-helpers.ts +++ b/src/lib/settings-helpers.ts @@ -6,7 +6,7 @@ export const isValidEmail = email => { export const linkedInRegex = /^(https?:\/\/)?(www\.)?(linkedin\.com\/in\/|lnkd\.in\/)[a-zA-Z0-9-]{3,30}\/?$/; -export const usernameRegex = /^(?!.*[&%:;*|>(null); useEffect(() => { let active = true; @@ -144,6 +147,16 @@ function ShareRecipientView({ username, token }: SharePageProps): JSX.Element { objects={view.objects} ownerUsername={username} readOnly + onObjectClick={setActiveObject} + /> + )} + + {activeObject && ( + setActiveObject(null)} /> )} diff --git a/src/utils/library/mapStrapiLibraries.ts b/src/utils/library/mapStrapiLibraries.ts index d9f3ec0f..d979f41a 100644 --- a/src/utils/library/mapStrapiLibraries.ts +++ b/src/utils/library/mapStrapiLibraries.ts @@ -65,16 +65,21 @@ export function mapStrapiLibraryEntryToCard( const aboutLibraryPlain = stripHtml( attributes.libraryDetails?.aboutLibrary ?? '', ); - const aboutMePlain = stripHtml(attributes.aboutMe); - const libraryName = - attributes.name?.trim() || aboutLibraryPlain || `Library ${id}`; + const username = attributes.user?.data?.attributes?.username; - const description = aboutMePlain || aboutLibraryPlain; + // Libraries are owner-scoped, so present them as "'s library" rather + // than the raw `name`/id. Fall back to the explicit name, then about text, + // then the id only when there's no linked username to build the label from. + const libraryName = username + ? `${username}'s library` + : attributes.name?.trim() || aboutLibraryPlain || `Library ${id}`; - const avatarUrl = attributes.avatar?.data?.attributes?.url; + // The card's "About" blurb is the library's own description, not the owner's + // personal bio (`aboutMe`) — that's surfaced separately in the Author panel. + const description = aboutLibraryPlain; - const username = attributes.user?.data?.attributes?.username; + const avatarUrl = attributes.avatar?.data?.attributes?.url; return { id, diff --git a/src/utils/library/schema/addObjectSchema.ts b/src/utils/library/schema/addObjectSchema.ts index 7b79bf79..3a5971d7 100644 --- a/src/utils/library/schema/addObjectSchema.ts +++ b/src/utils/library/schema/addObjectSchema.ts @@ -18,17 +18,32 @@ const coverImageSchema = z const URL_REGEX = /^(https?:\/\/)?([\w-]+(\.[\w-]+)+)(\/[\w-./?%&=]*)?$/; +// Single source of truth for the user-entered field limits, shared between the +// zod schemas and the CharCount indicators in AddObjectModal so the counter's +// `max` can never drift from what validation actually enforces. +export const OBJECT_FIELD_LIMITS = { + title: 200, + author: 100, + description: 5000, +} as const; + const bookSchema = z.object({ type: z.literal('book'), title: z .string() .min(1, 'Book title is required') - .max(200, 'Title must be 200 chars or less.'), - author: z.string().max(150, 'Author must be 150 chars or less.').optional(), + .max(OBJECT_FIELD_LIMITS.title, 'Title must be 200 chars or less.'), + author: z + .string() + .max(OBJECT_FIELD_LIMITS.author, 'Author must be 100 chars or less.') + .optional(), publicationDate: z.date().optional().nullable(), description: z .string() - .max(4000, 'Description must be 4000 chars or less.') + .max( + OBJECT_FIELD_LIMITS.description, + 'Description must be 5000 chars or less.', + ) .optional(), coverImage: coverImageSchema.optional().nullable(), }); @@ -42,11 +57,17 @@ const videoSchema = z.object({ title: z .string() .min(1, 'Video title is required') - .max(150, 'Title must be 150 chars or less.'), - author: z.string().max(100, 'Creator must be 100 chars or less.').optional(), + .max(OBJECT_FIELD_LIMITS.title, 'Title must be 200 chars or less.'), + author: z + .string() + .max(OBJECT_FIELD_LIMITS.author, 'Creator must be 100 chars or less.') + .optional(), description: z .string() - .max(5000, 'Description must be 5000 chars or less.') + .max( + OBJECT_FIELD_LIMITS.description, + 'Description must be 5000 chars or less.', + ) .optional(), coverImage: coverImageSchema.optional().nullable(), // Auto-derived from the URL host (e.g. "YouTube") — not user-entered. @@ -62,11 +83,17 @@ const audioSchema = z.object({ title: z .string() .min(1, 'Audio title is required') - .max(150, 'Title must be 150 chars or less.'), - author: z.string().max(100, 'Artist must be 100 chars or less.').optional(), + .max(OBJECT_FIELD_LIMITS.title, 'Title must be 200 chars or less.'), + author: z + .string() + .max(OBJECT_FIELD_LIMITS.author, 'Artist must be 100 chars or less.') + .optional(), description: z .string() - .max(5000, 'Description must be 5000 chars or less.') + .max( + OBJECT_FIELD_LIMITS.description, + 'Description must be 5000 chars or less.', + ) .optional(), coverImage: coverImageSchema.optional().nullable(), // Both auto-derived, never user-entered: `source` from the URL host, diff --git a/src/utils/library/schema/createTagSchema.ts b/src/utils/library/schema/createTagSchema.ts index a929cce4..96772547 100644 --- a/src/utils/library/schema/createTagSchema.ts +++ b/src/utils/library/schema/createTagSchema.ts @@ -10,11 +10,14 @@ export const createTagSchema = z.object({ .string() .min(1, 'Tag name is required') .min(2, 'Tag name must be at least 2 characters') - .max(50, 'Tag name must be 50 characters or less.') + .max(20, 'Tag name must be 20 characters or less.') .regex( TAG_NAME_REGEX, - 'Use letters, numbers, or - _ . ~ only (no spaces or special characters).' + 'Use letters, numbers, or - _ . ~ only (no spaces or special characters).', ), - description: z.string().max(500, 'Description must be 500 characters or less').optional(), + description: z + .string() + .max(500, 'Description must be 500 characters or less') + .optional(), color: z.string().min(1, 'Color is required'), }); diff --git a/src/utils/library/schema/editLibrarySchema.ts b/src/utils/library/schema/editLibrarySchema.ts index 3d71e238..edeb10c6 100644 --- a/src/utils/library/schema/editLibrarySchema.ts +++ b/src/utils/library/schema/editLibrarySchema.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; // Mirrors the backend username regex from docs/user-api.md §3: -// ^(?!.*[&%:;*|> < \\ # ? " =' + 'Username must be 3-30 characters, no whitespace, and must not contain & % : ; * | > < \\ # ? " =', ), - aboutMe: z.string().max(2000, 'About author must be 2000 characters or less').optional(), - aboutLibrary: z.string().max(4000, 'About library must be 4000 characters or less').optional(), + aboutMe: z + .string() + .max(2000, 'About author must be 2000 characters or less') + .optional(), + aboutLibrary: z + .string() + .max(4000, 'About library must be 4000 characters or less') + .optional(), }); export type EditLibraryFormData = z.infer; diff --git a/src/uxcore/data/uxcat/settings/en.ts b/src/uxcore/data/uxcat/settings/en.ts index 3795ccec..c6c22de9 100644 --- a/src/uxcore/data/uxcat/settings/en.ts +++ b/src/uxcore/data/uxcat/settings/en.ts @@ -10,7 +10,7 @@ const en = { saveBtn: 'Save', cancelBtn: 'Cancel', usernameValidationMessage: - 'Must be 6-30 characters. No special characters allowed.', + 'Must be 3-30 characters. No special characters allowed.', invalidLinkedIn: 'Please enter a valid LinkedIn URL.', }; diff --git a/src/uxcore/data/uxcat/settings/ru.ts b/src/uxcore/data/uxcat/settings/ru.ts index 13f5280b..98b2c047 100644 --- a/src/uxcore/data/uxcat/settings/ru.ts +++ b/src/uxcore/data/uxcat/settings/ru.ts @@ -10,7 +10,7 @@ const ru = { saveBtn: 'Сохранить', cancelBtn: 'Отмена', usernameValidationMessage: - 'Разрешено 6-30 символов, без специальных символов.', + 'Разрешено 3-30 символов, без специальных символов.', invalidLinkedIn: 'Не верный формат ссылки', }; diff --git a/src/uxcore/lib/uxcat-helpers.ts b/src/uxcore/lib/uxcat-helpers.ts index 645c75ea..6887d7b4 100644 --- a/src/uxcore/lib/uxcat-helpers.ts +++ b/src/uxcore/lib/uxcat-helpers.ts @@ -225,4 +225,4 @@ export const isValidEmail = email => { export const linkedInRegex = /^(https?:\/\/)?(www\.)?(linkedin\.com\/in\/|lnkd\.in\/)[a-zA-Z0-9-]{3,30}\/?$/; -export const usernameRegex = /^(?!.*[&%:;*|>> = { }, ], }, + '/uxcg': { + message: + "You're in **UXCG** — the UX Core Guide. Start from a real business problem and we hand you the exact biases bending it, plus concrete nudges to act on. 1000+ worked examples for product, growth, and HR teams — the applied half of UX Core.", + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: 'The library every case here is built on — 100+ biases', + }, + { + title: 'Awareness Test', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: 'See which biases bend your own calls first — under 7 minutes', + }, + { + title: 'Persona Map', + url: '/uxcp', + type: 'project', + nominated: true, + blurb: 'Build a persona out of the biases that actually drive people', + }, + ], + }, '/tools/longevity-protocol': { message: "You're in the **Longevity Protocol** — our take on long-haul health, distilled into a small set of practices we actually run on ourselves. Same principle as the rest of keepsimple: smart defaults beat willpower.", @@ -705,6 +732,35 @@ const PAGE_LANDINGS: Record> = { }, ], }, + '/uxcg': { + message: + 'Ты в **UXCG** — это гайд UX Core. Начинаешь с реальной бизнес-проблемы, а мы отдаём тебе те самые искажения, что её гнут, плюс конкретные нуджи, чтобы действовать. 1000+ разобранных примеров для продукта, роста и HR — прикладная половина UX Core.', + cards: [ + { + title: 'UX Core', + url: '/uxcore', + type: 'project', + nominated: true, + blurb: + 'Библиотека, на которой стоит каждый кейс здесь — 100+ искажений', + }, + { + title: 'Тест осознанности', + url: '/uxcat', + type: 'project', + nominated: true, + blurb: + 'Сначала узнай, какие искажения гнут твои решения — меньше 7 минут', + }, + { + title: 'Persona Map', + url: '/uxcp', + type: 'project', + nominated: true, + blurb: 'Собери персону из искажений, которые реально движут людьми', + }, + ], + }, '/tools/longevity-protocol': { message: 'Ты в **Longevity Protocol** — это наш взгляд на долгое здоровье, упакованный в небольшой набор практик, которые мы сами на себе и используем. Тот же принцип что и в остальном keepsimple: умные дефолты бьют силу воли.', @@ -797,6 +853,20 @@ const PAGE_LANDINGS: Record> = { const CURATED_LANDING_FIRED_KEY = 'ks_aux_curated_landing_v1'; const OPEN_KEY = 'ks_aux_open_v1'; const GREETED_PAGES_KEY = 'ks_aux_greeted_pages_v1'; + +/* True when the widget root is CSS-hidden (e.g. the mobile rule that + drops it while a bias modal is open). The open/closed flag follows the + visitor across pages and can stay `open` while the widget is invisible, + so it is NOT a safe gate for paid work on its own. Movement tracking + (page_view, dwell — all free/internal) keeps running while hidden; only + token-spending calls consult this so a hidden widget never burns money + by accident. */ +const isWidgetHidden = (): boolean => { + if (typeof document === 'undefined') return false; + const root = document.querySelector('.ks-aux-root'); + if (!root) return false; + return window.getComputedStyle(root).display === 'none'; +}; const curatedLandingPathKey = (rawUrl: string): string | null => { try { const u = new URL(rawUrl, window.location.origin); @@ -2256,14 +2326,16 @@ export function AskUxCore({ lang }: { lang: Lang }) { } /* Cost gate: the organic greeting is a paid AI call. Spend it - only when the panel is open — opening the pill is a deliberate - human gesture, and the open panel now follows the visitor across - pages, so an open panel marks a real user. Passers-by and - crawlers never open it; the server greeting route also drops - known-bot user-agents as a backstop. Then never pay twice for - the same page this session. Curated landings above are local - (free) and ungated. */ - if (!openRef.current) return; + only when the panel is open AND actually visible. Opening the + pill is a deliberate human gesture, and the open panel follows + the visitor across pages — but it can stay `open` while the + widget is CSS-hidden (mobile bias-modal rule), so we also bail + when hidden: a widget the visitor can't see must never burn + tokens. Passers-by and crawlers never open it; the server + greeting route also drops known-bot user-agents as a backstop. + Then never pay twice for the same page this session. Curated + landings above are local (free) and ungated. */ + if (!openRef.current || isWidgetHidden()) return; const greetKey = canonicalPathKey(rawUrl); if (hasGreetedPage(greetKey)) return; markGreetedPage(greetKey); @@ -2666,6 +2738,18 @@ export function AskUxCore({ lang }: { lang: Lang }) { } } + /* Same wallet guard as the organic greeting: a hidden widget never + spends. The card-click landing is user-initiated, but if the + destination renders the widget invisible (mobile bias-modal rule) + the line would be paid for and never seen — skip it. */ + if (isWidgetHidden()) { + if (pending.placeholderId !== undefined) { + const pid = pending.placeholderId; + setTurns(cur => cur.filter(tt => tt.id !== pid)); + } + return; + } + let cancelled = false; fetch('/api/concierge-landing', { method: 'POST', @@ -2824,6 +2908,17 @@ export function AskUxCore({ lang }: { lang: Lang }) { ); useEffect(() => { if (!isHighlightEnabledPage()) return; + /* No host highlighting while a UX Core / UXCG bias modal is open. + On desktop the widget now stays visible over the modal so the + visitor can talk about the bias they're reading — but lighting up + elements underneath the overlay is pointless (they're covered) and + noisy. The visitor can still be guided OUT to other pages via the + answer's cards; we just skip the in-page halo here. */ + if ( + typeof document !== 'undefined' && + document.querySelector('[class*="ModalOverlay"]') + ) + return; /* Host highlighting is gated on the panel being open: the Copilot lights up page elements only while it is ACTIVE. Collapsed pill = not active = no host highlight. Re-arm the flash guard on collapse @@ -2871,68 +2966,32 @@ export function AskUxCore({ lang }: { lang: Lang }) { }; }, [turns, open]); - /* Typewriter constants — every bit of bot-authored text in the - widget (concierge stream, homepage starters, landing turns) runs - through the same throttle so the panel reads at one consistent - tempo. 1 char / 22ms ≈ 45 chars/sec — smooth char-by-char reveal, - reads as deliberate rather than firehosed. */ - const STREAM_CHUNK = 1; - const STREAM_TICK = 22; + /* Settle delay before cards/suggestions attach after the text lands. */ const SETTLE_MS = 200; - /* Streaming typewriter — accepts a growing target via push() and - drips it into the named turn at the typewriter tempo. finish() - marks the target final and runs onDone once the displayed text - has caught up. Works for both live server streams (where the - target keeps growing) and pre-canned text (single push). */ + /* Bot-text reveal — every bit of bot-authored copy (concierge answer, + homepage starters, landing turns) goes through this. We deliberately + do NOT type char/word-by-char: progressive text re-parses the whole + markdown tree and reflows the line box on every step, which reads as + jittery. Instead the full message is committed once and the bubble + fades in smoothly via the `.ks-aux-a` CSS animation — one clean, + reflow-free reveal. push() keeps the latest target (server tokens + arrive in bursts; we just keep the newest), finish() paints it and + runs onDone after a short settle. */ const createTypewriter = (turnId: string) => { let target = ''; - let displayed = ''; - let timerActive = false; - let streamDone = false; - let pendingDone: (() => void) | null = null; - - const advance = () => { - if (displayed.length < target.length) { - const next = Math.min(displayed.length + STREAM_CHUNK, target.length); - displayed = target.slice(0, next); - setTurns(prev => - prev.map(tt => - tt.id === turnId ? { ...tt, answer: displayed } : tt, - ), - ); - } - if (displayed.length < target.length) { - window.setTimeout(advance, STREAM_TICK); - return; - } - timerActive = false; - if (streamDone && pendingDone) { - const cb = pendingDone; - pendingDone = null; - window.setTimeout(cb, SETTLE_MS); - } - }; - const kick = () => { - if (timerActive) return; - if (displayed.length >= target.length) return; - timerActive = true; - advance(); - }; - return { push: (next: string) => { target = next; - kick(); }, finish: (onDone: () => void) => { - streamDone = true; - if (displayed.length >= target.length) { - window.setTimeout(onDone, SETTLE_MS); - } else { - pendingDone = onDone; - kick(); - } + const finalText = target; + setTurns(prev => + prev.map(tt => + tt.id === turnId ? { ...tt, answer: finalText } : tt, + ), + ); + window.setTimeout(onDone, SETTLE_MS); }, }; }; diff --git a/widget/src/styles.css b/widget/src/styles.css index 5c82f646..8d027253 100644 --- a/widget/src/styles.css +++ b/widget/src/styles.css @@ -13,10 +13,15 @@ pointer-events: auto; } -/* Hide the whole widget while any UX Core / UXCG modal is open — the modal - owns the bottom corners (Prev/Next navigation pills live there). */ -body:has([class*='ModalOverlay']) .ks-aux-root { - display: none; +/* Hide the whole widget while any UX Core / UXCG modal is open — but only + on mobile, where the modal's Prev/Next pills live in the bottom corners + and would collide. On desktop the modal's arrows sit on the side edges, + so the bottom-right corner is free: we keep the widget visible there so + the visitor can talk to it about the bias they're reading. */ +@media (max-width: 480px) { + body:has([class*='ModalOverlay']) .ks-aux-root { + display: none; + } } @keyframes ks-aux-pulse { @@ -882,11 +887,11 @@ body:has([class*='ModalOverlay']) .ks-aux-root { line-height: 1.6; color: #1f1d1a; word-wrap: break-word; - animation: ks-aux-fade-in 320ms ease both; + animation: ks-aux-fade-in 460ms cubic-bezier(0.22, 0.61, 0.36, 1) both; } @keyframes ks-aux-fade-in { - from { opacity: 0; transform: translateY(4px); } + from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }