diff --git a/src/api/library/createShareLink.ts b/src/api/library/createShareLink.ts new file mode 100644 index 00000000..c441d4c0 --- /dev/null +++ b/src/api/library/createShareLink.ts @@ -0,0 +1,38 @@ +import type { + ICreateShareLinkPayload, + IShareLinkResult, +} from '@local-types/library/shareLink'; + +import axiosInstance from '@lib/library/axios'; + +// Pull the token out of whatever shape the backend returns. Strapi custom +// controllers vary between a bare body, a `{ data }` wrapper, and the full +// `{ data: { attributes } }` entity form — so probe each in turn. +// TODO(backend): pin the exact success shape and drop the extra fallbacks. +function readToken(body: unknown): string | null { + if (!body || typeof body !== 'object') return null; + const b = body as Record; + const data = b.data as Record | undefined; + const attributes = data?.attributes as Record | undefined; + const token = b.token ?? data?.token ?? attributes?.token; + return typeof token === 'string' && token.length > 0 ? token : null; +} + +// Mint a share link for the ordered object ids. Returns the token, or null on +// failure (the caller surfaces a retry message — the backend 400s on an empty +// selection, a non-public object, or more than 21 objects after expansion). +export const createShareLink = async ( + objectIds: number[], +): Promise => { + try { + const payload: ICreateShareLinkPayload = { objectIds }; + const { data } = await axiosInstance.post('/api/share-links', { + data: payload, + }); + const token = readToken(data); + return token ? { token } : null; + } catch (error) { + console.error('createShareLink failed:', error); + return null; + } +}; diff --git a/src/api/library/getShareLink.ts b/src/api/library/getShareLink.ts new file mode 100644 index 00000000..60945ae6 --- /dev/null +++ b/src/api/library/getShareLink.ts @@ -0,0 +1,58 @@ +import axios from 'axios'; + +import type { IShareLinkView } from '@local-types/library/shareLink'; + +import axiosInstance from '@lib/library/axios'; +import { mapSharedObject } from '@lib/library/mapSharedObject'; + +// Dig the ordered objects out of whatever wrapper the controller returns. The +// success shape isn't pinned, so probe the likely spots in turn: the share-link +// entity's `objects`, a Strapi relation list, or a bare array under `data`. +// TODO(backend): pin the success shape and drop the extra fallbacks. +function extractObjects(body: unknown): unknown[] { + if (!body || typeof body !== 'object') return []; + const b = body as Record; + const data = b.data as Record | unknown[] | undefined; + + if (Array.isArray(data)) return data; + if (Array.isArray(b.objects)) return b.objects; + + if (data && typeof data === 'object') { + const d = data as Record; + if (Array.isArray(d.objects)) return d.objects; + + const relation = d.objects as { data?: unknown } | undefined; + if (relation && Array.isArray(relation.data)) return relation.data; + + const attributes = d.attributes as Record | undefined; + const attrRelation = attributes?.objects as { data?: unknown } | undefined; + if (attrRelation && Array.isArray(attrRelation.data)) + return attrRelation.data; + } + + return []; +} + +// Open a share link by its token (public — the token is the credential). Returns +// a discriminated view so the recipient page can show a distinct UI for expired +// (410), unknown/bad token (404/400), and transport failures. +export const getShareLink = async (token: string): Promise => { + try { + const { data } = await axiosInstance.get( + `/api/share-links/${encodeURIComponent(token)}`, + ); + const objects = extractObjects(data) + .map(mapSharedObject) + .filter((o): o is NonNullable => o !== null); + return { status: 'ok', objects }; + } catch (error) { + const status = axios.isAxiosError(error) + ? error.response?.status + : undefined; + if (status === 410) return { status: 'expired', objects: [] }; + if (status === 404) return { status: 'notFound', objects: [] }; + if (status === 400) return { status: 'invalid', objects: [] }; + console.error('getShareLink failed:', error); + return { status: 'error', objects: [] }; + } +}; diff --git a/src/assets/icons/library/svg/chevron-up.svg b/src/assets/icons/library/svg/chevron-up.svg new file mode 100644 index 00000000..d654dd91 --- /dev/null +++ b/src/assets/icons/library/svg/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/library/svg/index.ts b/src/assets/icons/library/svg/index.ts index 02a419c7..cd70febe 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 ChevronUpIcon from './chevron-up.svg'; import CloseIcon from './close.svg'; import CompanyIcon from './company.svg'; import CopyIcon from './copy.svg'; @@ -35,6 +36,7 @@ export { BookShadowIcon, CalendarIcon, CheckIcon, + ChevronUpIcon, CloseIcon, CompanyIcon, CopyIcon, diff --git a/src/components/Context/library/GlobalStateContext.tsx b/src/components/Context/library/GlobalStateContext.tsx index 4709c326..5258a9ca 100644 --- a/src/components/Context/library/GlobalStateContext.tsx +++ b/src/components/Context/library/GlobalStateContext.tsx @@ -11,6 +11,8 @@ import { } from 'react'; import type { + ILibrary, + LibraryOwner, StrapiLibrariesResponse, StrapiSingleShelfEntry, } from '@local-types/library/library'; @@ -41,6 +43,20 @@ interface GlobalStateContextValue { */ currentShelves: StrapiSingleShelfEntry[]; setCurrentShelves: (shelves: StrapiSingleShelfEntry[]) => void; + /** + * Owner of the library currently being viewed — published by + * `LibraryTemplate` so the Sidebar's Author panel shows the library's owner + * rather than the logged-in viewer (`/api/users/me`). + */ + currentOwner: LibraryOwner | null; + setCurrentOwner: (owner: LibraryOwner | null) => void; + /** + * The full library entry currently being viewed — published by + * `LibraryTemplate`. The Sidebar edits this directly when it's the owner's + * own library, so there's no separate fetch to disagree with what's on screen. + */ + currentLibrary: ILibrary | null; + setCurrentLibrary: (library: ILibrary | null) => void; /** * True when the owner is on their own library with no library yet and lacks * the `can-create-library` feature flag. Published by `LibraryTemplate` so the @@ -68,6 +84,8 @@ export function GlobalStateProvider({ children }: { children: ReactNode }) { const [currentShelves, setCurrentShelves] = useState< StrapiSingleShelfEntry[] >([]); + const [currentOwner, setCurrentOwner] = useState(null); + const [currentLibrary, setCurrentLibrary] = useState(null); const [isCreateBlocked, setIsCreateBlocked] = useState(false); const didAttemptUserLoad = useRef(false); const didAttemptLibrariesLoad = useRef(false); @@ -132,6 +150,10 @@ export function GlobalStateProvider({ children }: { children: ReactNode }) { refetchLibraries, currentShelves, setCurrentShelves, + currentOwner, + setCurrentOwner, + currentLibrary, + setCurrentLibrary, isCreateBlocked, setIsCreateBlocked, }), @@ -146,6 +168,10 @@ export function GlobalStateProvider({ children }: { children: ReactNode }) { refetchLibraries, currentShelves, setCurrentShelves, + currentOwner, + setCurrentOwner, + currentLibrary, + setCurrentLibrary, isCreateBlocked, setIsCreateBlocked, ], diff --git a/src/components/Context/library/ShareSelectionContext.tsx b/src/components/Context/library/ShareSelectionContext.tsx new file mode 100644 index 00000000..7cec2e38 --- /dev/null +++ b/src/components/Context/library/ShareSelectionContext.tsx @@ -0,0 +1,127 @@ +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { MAX_SHARE_OBJECTS } from '@constants/library/common'; + +import type { IObject } from '@local-types/library/object'; + +interface ShareSelectionContextValue { + /** Selected objects in the order the owner wants them shared. */ + selectedObjects: IObject[]; + count: number; + /** True once the selection hits the backend's per-link cap. */ + limitReached: boolean; + isSelected: (id: number) => boolean; + /** Append if absent, remove if already selected. Append is a no-op at the cap. */ + toggle: (object: IObject) => void; + /** Append every object not already selected, stopping at the cap. */ + selectMany: (objects: IObject[]) => void; + remove: (id: number) => void; + /** Drop every object whose id is in the set — used to purge a shelf at once. */ + removeMany: (ids: number[]) => void; + reorder: (next: IObject[]) => void; + clear: () => void; +} + +const ShareSelectionContext = createContext< + ShareSelectionContextValue | undefined +>(undefined); + +export function ShareSelectionProvider({ children }: { children: ReactNode }) { + const [selectedObjects, setSelectedObjects] = useState([]); + + const isSelected = useCallback( + (id: number) => selectedObjects.some(o => o.id === id), + [selectedObjects], + ); + + const toggle = useCallback((object: IObject) => { + setSelectedObjects(prev => { + const exists = prev.some(o => o.id === object.id); + if (exists) return prev.filter(o => o.id !== object.id); + // Appending past the cap is silently ignored — the Select chip is the + // gate (disabled at the limit), this is just a belt-and-braces guard. + if (prev.length >= MAX_SHARE_OBJECTS) return prev; + return [...prev, object]; + }); + }, []); + + const selectMany = useCallback((objects: IObject[]) => { + setSelectedObjects(prev => { + const seen = new Set(prev.map(o => o.id)); + const additions: IObject[] = []; + for (const object of objects) { + if (prev.length + additions.length >= MAX_SHARE_OBJECTS) break; + if (seen.has(object.id)) continue; + seen.add(object.id); + additions.push(object); + } + return additions.length ? [...prev, ...additions] : prev; + }); + }, []); + + const remove = useCallback((id: number) => { + setSelectedObjects(prev => prev.filter(o => o.id !== id)); + }, []); + + const removeMany = useCallback((ids: number[]) => { + if (ids.length === 0) return; + const drop = new Set(ids); + setSelectedObjects(prev => prev.filter(o => !drop.has(o.id))); + }, []); + + const reorder = useCallback((next: IObject[]) => { + setSelectedObjects(next); + }, []); + + const clear = useCallback(() => setSelectedObjects([]), []); + + const value = useMemo( + () => ({ + selectedObjects, + count: selectedObjects.length, + limitReached: selectedObjects.length >= MAX_SHARE_OBJECTS, + isSelected, + toggle, + selectMany, + remove, + removeMany, + reorder, + clear, + }), + [ + selectedObjects, + isSelected, + toggle, + selectMany, + remove, + removeMany, + reorder, + clear, + ], + ); + + return ( + + {children} + + ); +} + +export function useShareSelection(): ShareSelectionContextValue { + const context = useContext(ShareSelectionContext); + + if (!context) { + throw new Error( + 'useShareSelection must be used within a ShareSelectionProvider', + ); + } + + return context; +} diff --git a/src/components/library/molecules/AudioCard/AudioCard.module.scss b/src/components/library/molecules/AudioCard/AudioCard.module.scss index c663c226..0be3bc2e 100644 --- a/src/components/library/molecules/AudioCard/AudioCard.module.scss +++ b/src/components/library/molecules/AudioCard/AudioCard.module.scss @@ -86,3 +86,15 @@ border-radius: 2px; display: block; } + +// Compact variant for the share-selection panel: a square 138×138 cover tile. +// The tag column is dropped so the cover fills the cell. +.row.compact { + width: 138px; + height: 138px; + gap: 0; + + .tags { + display: none; + } +} diff --git a/src/components/library/molecules/AudioCard/AudioCard.tsx b/src/components/library/molecules/AudioCard/AudioCard.tsx index c2afea4e..ec9b5904 100644 --- a/src/components/library/molecules/AudioCard/AudioCard.tsx +++ b/src/components/library/molecules/AudioCard/AudioCard.tsx @@ -15,6 +15,8 @@ export function AudioCard({ className, selected = false, onSelectToggle, + selectDisabled = false, + compact = false, }: AudioCardProps): JSX.Element { const { attributes } = object; const coverUrl = resolveStrapiUrl( @@ -33,7 +35,11 @@ export function AudioCard({ }; return ( -
+
{onSelectToggle && (
- +
)}
diff --git a/src/components/library/molecules/AudioCard/AudioCard.types.ts b/src/components/library/molecules/AudioCard/AudioCard.types.ts index 0796bd4f..eaab5090 100644 --- a/src/components/library/molecules/AudioCard/AudioCard.types.ts +++ b/src/components/library/molecules/AudioCard/AudioCard.types.ts @@ -7,4 +7,9 @@ export interface AudioCardProps { selected?: boolean; // When provided, a hover Select/Remove toggle is shown on the card. onSelectToggle?: () => void; + // Disables the Select chip (e.g. share-selection cap reached) while still + // allowing an already-selected card to be removed. + selectDisabled?: boolean; + // Shrinks the card to a square cover-only tile for the share-selection panel. + compact?: boolean; } diff --git a/src/components/library/molecules/BookCard/BookCard.module.scss b/src/components/library/molecules/BookCard/BookCard.module.scss index a70368b9..b492e601 100644 --- a/src/components/library/molecules/BookCard/BookCard.module.scss +++ b/src/components/library/molecules/BookCard/BookCard.module.scss @@ -110,3 +110,35 @@ border-radius: 2px; display: block; } + +// Compact variant for the share-selection panel: a smaller cover-only tile +// (97×138 cover, keeping the spine offset and shadow). Tags are dropped. +.row.compact { + width: 119px; + height: 138px; + + .card { + width: 119px; + height: 138px; + } + + .cover { + left: 22px; + width: 97px; + height: 138px; + } + + .shadow { + width: 119px; + height: 138px; + } + + .select { + left: 22px; + width: 97px; + } + + .tags { + display: none; + } +} diff --git a/src/components/library/molecules/BookCard/BookCard.tsx b/src/components/library/molecules/BookCard/BookCard.tsx index 272d53b1..f4006ba4 100644 --- a/src/components/library/molecules/BookCard/BookCard.tsx +++ b/src/components/library/molecules/BookCard/BookCard.tsx @@ -17,6 +17,8 @@ export function BookCard({ className, selected = false, onSelectToggle, + selectDisabled = false, + compact = false, }: BookCardProps): JSX.Element { const { attributes } = object; const coverUrl = resolveStrapiUrl( @@ -35,7 +37,11 @@ export function BookCard({ }; return ( -
+
{onSelectToggle && (
- +
)}
diff --git a/src/components/library/molecules/BookCard/BookCard.types.ts b/src/components/library/molecules/BookCard/BookCard.types.ts index b4e893ab..c3e602c7 100644 --- a/src/components/library/molecules/BookCard/BookCard.types.ts +++ b/src/components/library/molecules/BookCard/BookCard.types.ts @@ -7,4 +7,9 @@ export interface BookCardProps { selected?: boolean; // When provided, a hover Select/Remove toggle is shown on the card. onSelectToggle?: () => void; + // Disables the Select chip (e.g. share-selection cap reached) while still + // allowing an already-selected card to be removed. + selectDisabled?: boolean; + // Shrinks the card to a cover-only tile for the share-selection panel. + compact?: boolean; } diff --git a/src/components/library/molecules/SelectToggle/SelectToggle.module.scss b/src/components/library/molecules/SelectToggle/SelectToggle.module.scss index a12040d8..567ff62e 100644 --- a/src/components/library/molecules/SelectToggle/SelectToggle.module.scss +++ b/src/components/library/molecules/SelectToggle/SelectToggle.module.scss @@ -27,4 +27,13 @@ background: var(--brown-100); } } + + &.disabled { + cursor: not-allowed; + opacity: 0.45; + + &:hover { + background: var(--white); + } + } } diff --git a/src/components/library/molecules/SelectToggle/SelectToggle.tsx b/src/components/library/molecules/SelectToggle/SelectToggle.tsx index 96bf522f..c88bbdb6 100644 --- a/src/components/library/molecules/SelectToggle/SelectToggle.tsx +++ b/src/components/library/molecules/SelectToggle/SelectToggle.tsx @@ -11,6 +11,7 @@ export function SelectToggle({ selected, onToggle, className, + disabled = false, }: SelectToggleProps): JSX.Element { // The toggle sits on top of a card that is itself a button, so keep the // click/press/drag from bubbling up and opening the object overview. @@ -26,10 +27,12 @@ export function SelectToggle({ type="button" className={classNames(styles.toggle, className, { [styles.selected]: selected, + [styles.disabled]: disabled, })} onClick={handleClick} onPointerDown={stop} onKeyDown={stop} + disabled={disabled} aria-pressed={selected} aria-label={selected ? 'Remove from selection' : 'Select'} > diff --git a/src/components/library/molecules/SelectToggle/SelectToggle.types.ts b/src/components/library/molecules/SelectToggle/SelectToggle.types.ts index a12b60b3..f9813e30 100644 --- a/src/components/library/molecules/SelectToggle/SelectToggle.types.ts +++ b/src/components/library/molecules/SelectToggle/SelectToggle.types.ts @@ -2,4 +2,7 @@ export interface SelectToggleProps { selected: boolean; onToggle: () => void; className?: string; + // Blocks adding once the share-selection cap is reached. A selected toggle is + // never disabled, so the object can still be removed. + disabled?: boolean; } diff --git a/src/components/library/molecules/SharedWithYouModal/SharedWithYouModal.module.scss b/src/components/library/molecules/SharedWithYouModal/SharedWithYouModal.module.scss new file mode 100644 index 00000000..b9672962 --- /dev/null +++ b/src/components/library/molecules/SharedWithYouModal/SharedWithYouModal.module.scss @@ -0,0 +1,44 @@ +.modal { + width: 100% !important; + max-width: 420px !important; + background-color: var(--white) !important; +} + +.wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 40px 32px 32px; + text-align: center; +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--cream); + color: var(--brown); + + svg { + width: 24px; + height: 24px; + } +} + +.title { + color: var(--gray-darkest); +} + +.text { + max-width: 320px; + color: var(--black-transparent-300); +} + +.action { + margin-top: 8px; + width: auto !important; +} diff --git a/src/components/library/molecules/SharedWithYouModal/SharedWithYouModal.tsx b/src/components/library/molecules/SharedWithYouModal/SharedWithYouModal.tsx new file mode 100644 index 00000000..01e0fb1b --- /dev/null +++ b/src/components/library/molecules/SharedWithYouModal/SharedWithYouModal.tsx @@ -0,0 +1,52 @@ +import React, { JSX } from 'react'; + +import { ShareIcon } from '@icons/library/svg'; + +import { Text, TypographyVariant } from '@components/library/atoms/Text'; +import { + Button, + ButtonSize, + ButtonType, +} from '@components/library/molecules/Button'; +import { Modal, useModalClose } from '@components/library/molecules/Modal'; + +import type { SharedWithYouModalProps } from './SharedWithYouModal.types'; + +import styles from './SharedWithYouModal.module.scss'; + +export function SharedWithYouModal({ + ownerName, + itemCount, + onViewSelection, + onClose, +}: SharedWithYouModalProps): JSX.Element { + const { closeRef } = useModalClose(onClose); + + const itemLabel = itemCount === 1 ? 'item' : 'items'; + + return ( + +
+
+ +
+ + {ownerName} shared a selection with you + + + {ownerName} has shared {itemCount} {itemLabel} in a specific sequence + with you. Open the selection to view them in order. + +
+
+ ); +} diff --git a/src/components/library/molecules/SharedWithYouModal/SharedWithYouModal.types.ts b/src/components/library/molecules/SharedWithYouModal/SharedWithYouModal.types.ts new file mode 100644 index 00000000..d1c62559 --- /dev/null +++ b/src/components/library/molecules/SharedWithYouModal/SharedWithYouModal.types.ts @@ -0,0 +1,9 @@ +export interface SharedWithYouModalProps { + // Display name (or username) of the library owner who minted the link. + ownerName: string; + // How many objects the link carries — shown so the recipient knows the size + // of the selection before opening it. + itemCount: number; + onViewSelection: () => void; + onClose: () => void; +} diff --git a/src/components/library/molecules/SharedWithYouModal/index.tsx b/src/components/library/molecules/SharedWithYouModal/index.tsx new file mode 100644 index 00000000..0b94f72e --- /dev/null +++ b/src/components/library/molecules/SharedWithYouModal/index.tsx @@ -0,0 +1,2 @@ +export { SharedWithYouModal } from './SharedWithYouModal'; +export type { SharedWithYouModalProps } from './SharedWithYouModal.types'; diff --git a/src/components/library/molecules/VideoCard/VideoCard.module.scss b/src/components/library/molecules/VideoCard/VideoCard.module.scss index 9b4f5a65..b1e763e7 100644 --- a/src/components/library/molecules/VideoCard/VideoCard.module.scss +++ b/src/components/library/molecules/VideoCard/VideoCard.module.scss @@ -96,3 +96,23 @@ overflow: hidden; text-overflow: ellipsis; } + +// Compact variant for the share-selection panel: a bare 231×138 thumbnail +// (no padding, no title bar, no caption). +.card.compact { + width: 231px; + height: 138px; + padding: 0; + border-radius: 0; + + .bar, + .title { + display: none; + } + + .select { + top: 8px; + left: 8px; + right: 8px; + } +} diff --git a/src/components/library/molecules/VideoCard/VideoCard.tsx b/src/components/library/molecules/VideoCard/VideoCard.tsx index 1bcda026..15ab7c37 100644 --- a/src/components/library/molecules/VideoCard/VideoCard.tsx +++ b/src/components/library/molecules/VideoCard/VideoCard.tsx @@ -18,6 +18,8 @@ export function VideoCard({ className, selected = false, onSelectToggle, + selectDisabled = false, + compact = false, }: VideoCardProps): JSX.Element { const { attributes } = object; const coverUrl = resolveStrapiUrl( @@ -38,6 +40,7 @@ export function VideoCard({
{onSelectToggle && (
- +
)}
diff --git a/src/components/library/molecules/VideoCard/VideoCard.types.ts b/src/components/library/molecules/VideoCard/VideoCard.types.ts index 90d04297..c38d8aac 100644 --- a/src/components/library/molecules/VideoCard/VideoCard.types.ts +++ b/src/components/library/molecules/VideoCard/VideoCard.types.ts @@ -7,4 +7,9 @@ export interface VideoCardProps { selected?: boolean; // When provided, a hover Select/Remove toggle is shown on the card. onSelectToggle?: () => void; + // Disables the Select chip (e.g. share-selection cap reached) while still + // allowing an already-selected card to be removed. + selectDisabled?: boolean; + // Shrinks the card to a thumbnail-only tile for the share-selection panel. + compact?: boolean; } diff --git a/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx b/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx index f411fd33..1edb5087 100644 --- a/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx +++ b/src/components/library/organisms/EditLibraryModal/EditLibraryModal.tsx @@ -14,7 +14,7 @@ import { useForm } from 'react-hook-form'; import type { IUpdateLibraryPayload } from '@local-types/library/library'; import type { IUpdateMeErrorBody } from '@local-types/library/user'; -import { getMyLibrary } from '@api/library/getMyLibrary'; +import { createLibrary } from '@api/library/createLibrary'; import { updateLibrary } from '@api/library/updateLibrary'; import { uploadFile } from '@api/library/upload/uploadFile'; import { getUserInfo } from '@api/library/user/getUserInfo'; @@ -68,11 +68,11 @@ export function EditLibraryModal(props: EditLibraryModalProps): JSX.Element { const { closeRef, close } = useModalClose(onClose); const currentAvatarUrl = absoluteUrl( - library.attributes.avatar?.data?.attributes.url, + library?.attributes.avatar?.data?.attributes.url, ); - const currentAboutMe = stripHtml(library.attributes.aboutMe); + const currentAboutMe = stripHtml(library?.attributes.aboutMe); const currentAboutLibrary = stripHtml( - library.attributes.libraryDetails?.aboutLibrary, + library?.attributes.libraryDetails?.aboutLibrary, ); const currentUsername = accountData?.username ?? ''; @@ -167,11 +167,26 @@ export function EditLibraryModal(props: EditLibraryModalProps): JSX.Element { if (uploadedAvatarId !== undefined) libraryPayload.avatar = uploadedAvatarId; + const hasLibraryChanges = Object.keys(libraryPayload).length > 0; + + // Bootstrap the library on first save — a permitted owner can open this + // modal before any library row exists (the row is created lazily, the + // same way adding the first shelf does). Only create one if there's an + // actual library change to persist; a username-only edit needs no library. + let libraryId = library?.id ?? null; + if (hasLibraryChanges && libraryId == null && accountData?.id != null) { + libraryId = await createLibrary(accountData.id); + if (libraryId == null) { + setTopError('Could not create your library. Please try again.'); + return; + } + } + const usernameChanged = data.username !== currentUsername; const [libraryResult, userResult] = await Promise.allSettled([ - Object.keys(libraryPayload).length > 0 - ? updateLibrary(library.id, libraryPayload) + hasLibraryChanges && libraryId != null + ? updateLibrary(libraryId, libraryPayload) : Promise.resolve(null), usernameChanged ? updateMe({ username: data.username }) @@ -198,13 +213,12 @@ export function EditLibraryModal(props: EditLibraryModalProps): JSX.Element { return; // leave the modal open so the user can fix it } - // 3. Refresh accountData (username may have changed) and fetch fresh library. - const [freshUser, freshLibrary] = await Promise.all([ - getUserInfo(), - accountData?.id ? getMyLibrary(accountData.id) : Promise.resolve(null), - ]); + // 3. Refresh accountData (username may have changed). The library is + // reloaded by the caller via the resolved id — a direct GET by id, which + // (unlike the owner relation-filter) reliably resolves a just-created row. + const freshUser = await getUserInfo(); if (freshUser) setAccountData(freshUser); - if (freshLibrary) onSaved?.(freshLibrary); + if (libraryId != null) onSaved?.(libraryId); onClose(); } catch (error) { console.error('EditLibraryModal save failed:', error); diff --git a/src/components/library/organisms/EditLibraryModal/EditLibraryModal.types.ts b/src/components/library/organisms/EditLibraryModal/EditLibraryModal.types.ts index 9c94b58d..2614c60e 100644 --- a/src/components/library/organisms/EditLibraryModal/EditLibraryModal.types.ts +++ b/src/components/library/organisms/EditLibraryModal/EditLibraryModal.types.ts @@ -2,7 +2,11 @@ import type { ILibrary } from '@local-types/library/library'; export interface EditLibraryModalProps { className?: string; - library: ILibrary; + // Null when the owner has create permission but hasn't created a library yet — + // the modal bootstraps one on first save. + library: ILibrary | null; onClose: () => void; - onSaved?: (library: ILibrary) => void; + // Receives the resolved library id (existing, or freshly created on save) so + // the caller can reload by direct id instead of the restricted owner filter. + onSaved?: (libraryId: number) => void; } diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx index b905e8f3..9805ba7f 100644 --- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx +++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx @@ -1,13 +1,12 @@ import { resolveStrapiUrl } from '@utils/library/resolveStrapiUrl'; import classNames from 'classnames'; -import React, { JSX, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { JSX, useCallback, useMemo, useState } from 'react'; import type { Difficulty, IObject, OverallRating, } from '@local-types/library/object'; -import type { IShelf } from '@local-types/library/shelf'; import { useClickOutside } from '@hooks/library/useClickOutside'; @@ -15,7 +14,6 @@ import { sanitizeHtml } from '@lib/sanitizeHtml'; import { deleteObject } from '@api/library/object/deleteObject'; import { updateObject } from '@api/library/object/updateObject'; -import { getShelvesList } from '@api/library/shelf/getShelvesList'; import { CalendarIcon, @@ -26,6 +24,7 @@ import { ShareIcon, } from '@icons/library/svg'; +import { useGlobalState } from '@components/Context/library/GlobalStateContext'; import { IconName } from '@components/library/atoms/Icon'; import { TagType, @@ -110,7 +109,7 @@ export function ObjectOverviewModal( const [moveLoading, setMoveLoading] = useState(false); const [moveError, setMoveError] = useState(null); - const [shelfOptions, setShelfOptions] = useState([]); + const { currentShelves } = useGlobalState(); const closeMenu = useCallback(() => setMenuOpen(false), []); const menuRef = useClickOutside(closeMenu); @@ -158,25 +157,27 @@ export function ObjectOverviewModal( // Depends on a public-read endpoint or signed URL from backend. }; - const currentShelfId = attributes.shelf?.data?.id; - - useEffect(() => { - if (!isOwner) return; - let cancelled = false; - getShelvesList(objectType).then(res => { - if (cancelled) return; - setShelfOptions(res?.data ?? []); - }); - return () => { - cancelled = true; - }; - }, [isOwner, objectType]); + // Fall back to `defaultShelfId` (the shelf this object is rendered under): + // PUT responses don't populate the `shelf` relation, so after a rating edit + // `attributes.shelf?.data?.id` can be empty — without the fallback the + // current shelf leaks back into the Move-To options. + const currentShelfId = attributes.shelf?.data?.id ?? defaultShelfId; + + // Move-To targets come from the viewed library's own shelves (published to + // GlobalState by LibraryTemplate), not a global `/single-shelves` fetch — + // that returned every user's public shelves, so the dropdown offered foreign + // shelves the backend then 403s on move. Scope to this object's type since a + // book can't move into a video shelf. + const ownShelves = useMemo( + () => currentShelves.filter(s => s.attributes.type === objectType), + [currentShelves, objectType], + ); const moveToOptions = useMemo(() => { - return shelfOptions + return ownShelves .filter(s => s.id !== currentShelfId) .map(s => ({ value: String(s.id), label: s.attributes.name })); - }, [shelfOptions, currentShelfId]); + }, [ownShelves, currentShelfId]); // PUT responses don't populate relations we didn't touch, so a rating-only // update drops cover/tags/shelf from the response. Carry them forward from @@ -231,7 +232,7 @@ export function ObjectOverviewModal( if (!value) return; const targetId = Number(value); if (!Number.isFinite(targetId)) return; - const targetShelf = shelfOptions.find(s => s.id === targetId); + const targetShelf = ownShelves.find(s => s.id === targetId); if (!targetShelf) return; setMoveToShelfId(value); setMoveLoading(true); @@ -251,7 +252,7 @@ export function ObjectOverviewModal( id: targetShelf.id, attributes: { name: targetShelf.attributes.name, - type: targetShelf.attributes.type, + type: objectType, order: targetShelf.attributes.order, }, }, diff --git a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.module.scss b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.module.scss new file mode 100644 index 00000000..8109d9ff --- /dev/null +++ b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.module.scss @@ -0,0 +1,176 @@ +.panel { + position: fixed; + left: 0; + // Stop at the sidebar's left edge (331px sticky column) so the sheet spans + // only the main library area; the sidebar overlays below 1024px, so go + // full-width there. + right: 331px; + bottom: 0; + z-index: 40; + display: flex; + flex-direction: column; + max-height: 440px; + background: var(--white-warm); + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.08); + + @media (max-width: 1024px) { + right: 0; + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 24px 32px; + background: var(--cream); + border-bottom: 1px solid var(--beige); +} + +.heading { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 0; + border: none; + background: none; + cursor: pointer; +} + +.chevron { + width: 24px; + height: 24px; + color: #868686; + transition: transform 0.2s ease; + + &.chevronCollapsed { + transform: rotate(180deg); + } +} + +.title { + text-transform: uppercase; + letter-spacing: 0.05em; + color: #868686; +} + +.actions { + display: flex; + align-items: center; + gap: 16px; +} + +// Animated collapse wrapper. The grid 1fr→0fr transition smoothly shrinks the +// whole section to nothing (the chevron points at the header), and a single 1fr +// row in this flex child resolves to the body's content height when the panel +// is uncapped, or to the leftover space (so .body scrolls) when it hits 440px. +.bodyWrap { + flex: 1; + min-height: 0; + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.28s ease; +} + +.bodyWrapCollapsed { + grid-template-rows: 0fr; +} + +.body { + // border-box so the 24px padding collapses to zero too (otherwise it would + // leave a ~48px stub when the grid row reaches 0fr). + box-sizing: border-box; + min-height: 0; + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px 32px; + overflow-y: auto; +} + +.warning { + color: var(--brown); +} + +.error { + color: var(--red-600); +} + +.scroller { + min-height: 0; +} + +.grid { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 32px; +} + +.item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.removePill { + display: inline-flex; + align-items: center; + justify-content: center; + height: 25px; + padding: 0 16px; + border: 1px solid var(--beige); + border-radius: 6px; + cursor: pointer; + background: var(--white); + box-shadow: 0 2.27px 4.55px rgba(0, 0, 0, 0.06); + + svg { + width: 16px; + height: 16px; + } + + &:hover { + background: var(--off-white); + } +} + +.sequence { + font-size: 20px; + line-height: 1; + letter-spacing: 0.05em; + text-align: center; + color: #868686; +} + +.cardHandle { + &.draggable { + cursor: grab; + touch-action: none; + + &:active { + cursor: grabbing; + } + } +} + +.linkRow { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 1px solid var(--beige); + border-radius: 8px; + background: var(--off-white); +} + +.linkText { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx new file mode 100644 index 00000000..101353bd --- /dev/null +++ b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx @@ -0,0 +1,333 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + rectSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import classNames from 'classnames'; +import React, { JSX, useEffect, useMemo, useState } from 'react'; + +import { KEEPSIMPLE_URL, MAX_SHARE_OBJECTS } from '@constants/library/common'; + +import type { IObject } from '@local-types/library/object'; + +import { createShareLink } from '@api/library/createShareLink'; + +import { ChevronUpIcon, CloseIcon, ShareIcon } from '@icons/library/svg'; + +import { Text, TypographyVariant } from '@components/library/atoms/Text'; +import { AudioCard } from '@components/library/molecules/AudioCard'; +import { BookCard } from '@components/library/molecules/BookCard'; +import { + Button, + ButtonSize, + ButtonType, + IconPosition, +} from '@components/library/molecules/Button'; +import { ConfirmationModal } from '@components/library/molecules/ConfirmationModal'; +import { VideoCard } from '@components/library/molecules/VideoCard'; + +import type { ShareSelectionPanelProps } from './ShareSelectionPanel.types'; + +import styles from './ShareSelectionPanel.module.scss'; + +const SHARE_BASE_URL = process.env.NEXT_PUBLIC_DOMAIN ?? KEEPSIMPLE_URL; + +function ObjectCard({ object }: { object: IObject }): JSX.Element { + switch (object.attributes.type) { + case 'video': + return ; + case 'audio': + return ; + default: + return ; + } +} + +function SortableItem(props: { + object: IObject; + position: number; + readOnly: boolean; + onRemove?: (id: number) => void; +}): JSX.Element { + const { object, position, readOnly, onRemove } = props; + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: object.id, disabled: readOnly }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.6 : 1, + }; + + const sequence = ( + + {position} + + ); + + return ( +
+ {/* Recipient sees the sequence above the cover; the owner sees a remove + control there instead and the sequence below. */} + {readOnly ? ( + sequence + ) : ( + + )} + + {/* Drag handle wraps the card; the card carries no onClick here, so a + pointer-press always starts a drag instead of opening the overview. */} +
+ +
+ + {!readOnly && sequence} +
+ ); +} + +export function ShareSelectionPanel({ + objects, + ownerUsername, + readOnly = false, + limitReached = false, + onReorder, + onRemove, + onClear, + className, +}: ShareSelectionPanelProps): JSX.Element | null { + const [collapsed, setCollapsed] = useState(false); + const [pendingRemoveId, setPendingRemoveId] = useState(null); + const [isSharing, setIsSharing] = useState(false); + const [shareUrl, setShareUrl] = useState(null); + const [shareError, setShareError] = useState(null); + const [copied, setCopied] = useState(false); + + // The ids+order are the share's identity — once they change, a previously + // minted link no longer matches what's on screen, so drop it. + const orderKey = useMemo(() => objects.map(o => o.id).join(','), [objects]); + useEffect(() => { + setShareUrl(null); + setShareError(null); + setCopied(false); + }, [orderKey]); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = objects.findIndex(o => o.id === active.id); + const newIndex = objects.findIndex(o => o.id === over.id); + if (oldIndex === -1 || newIndex === -1) return; + onReorder?.(arrayMove(objects, oldIndex, newIndex)); + }; + + const handleShare = async () => { + setIsSharing(true); + setShareError(null); + try { + const result = await createShareLink(objects.map(o => o.id)); + if (!result) { + setShareError('Could not create the link. Please try again.'); + return; + } + setShareUrl( + `${SHARE_BASE_URL}/library/${ownerUsername}/share/${result.token}`, + ); + } finally { + setIsSharing(false); + } + }; + + const handleCopy = async () => { + if (!shareUrl) return; + try { + await navigator.clipboard.writeText(shareUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setShareError('Could not copy. Copy the link manually.'); + } + }; + + const pendingRemove = objects.find(o => o.id === pendingRemoveId) ?? null; + + if (objects.length === 0) return null; + + return ( +
+
+ + + {!readOnly && ( +
+
+ )} +
+ + {/* Always mounted so the chevron can animate the whole section open/closed + via the grid-rows collapse (see .bodyWrap) instead of unmounting. */} +
+
+ {!readOnly && limitReached && ( + + You've reached the maximum of {MAX_SHARE_OBJECTS} objects. + Remove one to add another. + + )} + +
+ + o.id)} + strategy={rectSortingStrategy} + > +
+ {objects.map((object, index) => ( + + ))} +
+
+
+
+ + {!readOnly && shareUrl && ( +
+ + {shareUrl} + +
+ )} + + {!readOnly && shareError && ( + + {shareError} + + )} +
+
+ + {pendingRemove && ( + setPendingRemoveId(null)} + onConfirm={() => { + onRemove?.(pendingRemove.id); + setPendingRemoveId(null); + }} + /> + )} +
+ ); +} diff --git a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.types.ts b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.types.ts new file mode 100644 index 00000000..d7f1c773 --- /dev/null +++ b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.types.ts @@ -0,0 +1,16 @@ +import type { IObject } from '@local-types/library/object'; + +export interface ShareSelectionPanelProps { + // Objects in the order they'll be shared (owner) or were shared (recipient). + objects: IObject[]; + // Decorates the generated share URL: `${DOMAIN}/library/${ownerUsername}/share/${token}`. + ownerUsername: string; + // Recipient view: sequence numbers, no remove/reorder/share controls. + readOnly?: boolean; + // Selection hit the 21-object cap — show the warning copy. + limitReached?: boolean; + onReorder?: (next: IObject[]) => void; + onRemove?: (id: number) => void; + onClear?: () => void; + className?: string; +} diff --git a/src/components/library/organisms/ShareSelectionPanel/index.tsx b/src/components/library/organisms/ShareSelectionPanel/index.tsx new file mode 100644 index 00000000..54f4bdc0 --- /dev/null +++ b/src/components/library/organisms/ShareSelectionPanel/index.tsx @@ -0,0 +1,2 @@ +export * from './ShareSelectionPanel'; +export * from './ShareSelectionPanel.types'; diff --git a/src/components/library/organisms/Shelf/Shelf.tsx b/src/components/library/organisms/Shelf/Shelf.tsx index 42fc7815..f3d1f812 100644 --- a/src/components/library/organisms/Shelf/Shelf.tsx +++ b/src/components/library/organisms/Shelf/Shelf.tsx @@ -1,5 +1,6 @@ import classNames from 'classnames'; import Image from 'next/image'; +import { useRouter } from 'next/router'; import React, { JSX, useCallback, @@ -12,6 +13,8 @@ import React, { import type { IObject, ObjectType } from '@local-types/library/object'; import type { ShelfVisibility } from '@local-types/library/shelf'; +import { objectIdFromSlug, objectSlug } from '@lib/library/objectSlug'; + import { deleteShelf } from '@api/library/shelf/deleteShelf'; import { updateShelf } from '@api/library/shelf/updateShelf'; @@ -25,6 +28,7 @@ import { VideoIcon, } from '@icons/library/svg'; +import { useShareSelection } from '@components/Context/library/ShareSelectionContext'; import { IconName } from '@components/library/atoms/Icon'; import { Text, TypographyVariant } from '@components/library/atoms/Text'; import { AudioCard } from '@components/library/molecules/AudioCard'; @@ -156,18 +160,29 @@ export function Shelf(props: ShelfProps): JSX.Element { const typeIcon = SHELF_TYPE_ICON[shelfType] ?? ; const typeLabel = SHELF_TYPE_LABEL[shelfType] ?? 'item'; + 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 + // id so the right shelf — the one actually holding that object — renders the + // overview, with its real shelf context, and a title edit can't orphan the URL. + const usernameParam = router.query.username; + const urlUsername = Array.isArray(usernameParam) + ? usernameParam[0] + : (usernameParam ?? ''); + const objectParam = router.query.object; + const activeSlug = Array.isArray(objectParam) ? objectParam[0] : objectParam; + const activeObjectId = objectIdFromSlug(activeSlug); + const [isAddOpen, setIsAddOpen] = useState(false); - const [activeObject, setActiveObject] = useState(null); - const [selectedIds, setSelectedIds] = useState>(new Set()); - - const toggleSelected = useCallback((id: number) => { - setSelectedIds(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }, []); + // Selection is shared across all shelves (one share link spans the whole + // library), so it lives in context rather than per-shelf local state. + const { + isSelected, + toggle: toggleSelection, + selectMany, + removeMany, + limitReached, + } = useShareSelection(); const [deleteShelfOpen, setDeleteShelfOpen] = useState(false); const [deleteShelfLoading, setDeleteShelfLoading] = useState(false); const [deleteShelfError, setDeleteShelfError] = useState(null); @@ -229,8 +244,41 @@ export function Shelf(props: ShelfProps): JSX.Element { const openAdd = () => setIsAddOpen(true); const closeAdd = () => setIsAddOpen(false); - const openObject = (object: IObject) => setActiveObject(object); - const closeObject = () => setActiveObject(null); + + // Open/close are URL transitions, kept shallow so the library underneath is + // never refetched or unmounted — only the overview modal appears/disappears + // over the current page. `scroll: false` keeps the shelf scroll position. + const openObject = (object: IObject) => { + void router.push( + `/library/${encodeURIComponent(urlUsername)}/${objectSlug(object)}`, + undefined, + { shallow: true, scroll: false }, + ); + }; + const closeObject = () => { + void router.push(`/library/${encodeURIComponent(urlUsername)}`, undefined, { + shallow: true, + scroll: false, + }); + }; + + // The object this shelf currently owns *and* the URL points at, if any. + const activeObject = + activeObjectId != null + ? (objects.find(o => o.id === activeObjectId) ?? null) + : null; + + // "Select shelf" bulk-toggles every object on this shelf into the share + // selection. Like the per-card chip, it's owner-only and public-only since + // private objects aren't shareable. When all are already selected it clears + // them; otherwise it adds them (selectMany stops at the cap). + const canSelectShelf = isOwner && visibility === 'public'; + const allSelected = + objects.length > 0 && objects.every(o => isSelected(o.id)); + const handleSelectShelf = () => { + if (allSelected) removeMany(objects.map(o => o.id)); + else selectMany(objects); + }; const openRename = () => { setRenameError(null); @@ -248,6 +296,12 @@ export function Shelf(props: ShelfProps): JSX.Element { const previous = visibility; if (previous === value) return; setVisibility(value); + // Only public-shelf objects are shareable. Going private strips this + // shelf's objects from the share selection now, so the selection never + // carries objects the backend would reject when the link is minted. + if (value === 'private') { + removeMany(objects.map(o => o.id)); + } updateShelf(shelf.id, { visibility: value }).catch(e => { console.error('[Shelf] failed to update visibility', e); setVisibility(previous); @@ -311,8 +365,9 @@ export function Shelf(props: ShelfProps): JSX.Element { closeObject(); return; } + // No need to track the object locally — it flows back through `objects` and + // the URL still points at its id, so the overview re-renders with the edit. onObjectUpdated?.(shelf.id, updated); - setActiveObject(updated); }; const handleDeleted = (id: number) => { @@ -364,15 +419,18 @@ export function Shelf(props: ShelfProps): JSX.Element {
-
); } diff --git a/src/lib/library/mapSharedObject.ts b/src/lib/library/mapSharedObject.ts new file mode 100644 index 00000000..522dde4b --- /dev/null +++ b/src/lib/library/mapSharedObject.ts @@ -0,0 +1,72 @@ +import type { IMedia } from '@local-types/library/media'; +import type { IObject, IObjectAttributes } from '@local-types/library/object'; +import type { IStrapiRelation } from '@local-types/library/strapi'; + +// The GET /api/share-links/:token payload's object shape isn't pinned yet: +// Strapi may hand back the standard nested entity (`{ id, attributes }`) — the +// same form `getSingleLibrary` returns — or a flattened record. Normalize either +// into the nested IObject the card components read. +// TODO(backend): pin the response shape and drop the flat-record fallback. +export function mapSharedObject(raw: unknown): IObject | null { + if (!raw || typeof raw !== 'object') return null; + const record = raw as Record; + + const id = typeof record.id === 'number' ? record.id : Number(record.id); + if (!Number.isFinite(id)) return null; + + // Already nested (`{ id, attributes }`) — the cards consume this directly. + if (record.attributes && typeof record.attributes === 'object') { + return raw as IObject; + } + + // Flat record — lift the scalar fields under `attributes` and rewrap the cover + // image into the `{ data: { attributes: { url } } }` relation the cards read. + const { coverImage, ...rest } = record; + const attributes = { + ...(rest as Partial), + coverImage: wrapCoverImage(coverImage), + } as IObjectAttributes; + + return { id, attributes }; +} + +// Accept either a bare URL string or a media-like object and produce the Strapi +// relation envelope `{ data: { id, attributes: { url, … } } }`. Returns +// undefined when there's no usable cover. +function wrapCoverImage(value: unknown): IStrapiRelation | undefined { + if (!value) return undefined; + + if (typeof value === 'string') { + return { data: media(0, value) }; + } + + if (typeof value === 'object') { + const v = value as Record; + // Already a Strapi relation — pass through untouched. + if ('data' in v) { + return value as IStrapiRelation; + } + if (typeof v.url === 'string') { + const innerId = typeof v.id === 'number' ? v.id : 0; + return { data: media(innerId, v.url, v) }; + } + } + + return undefined; +} + +function media( + id: number, + url: string, + source: Record = {}, +): IMedia { + return { + id, + attributes: { + url, + name: String(source.name ?? ''), + mime: String(source.mime ?? ''), + size: Number(source.size ?? 0), + }, + }; +} diff --git a/src/lib/library/objectSlug.ts b/src/lib/library/objectSlug.ts new file mode 100644 index 00000000..5387ba75 --- /dev/null +++ b/src/lib/library/objectSlug.ts @@ -0,0 +1,31 @@ +import type { IObject } from '@local-types/library/object'; + +// Title → URL-safe slug: lowercase, accents stripped (NFKD splits an accented +// letter into base + combining mark, which the non-alphanumeric pass below then +// drops), every run of non-alphanumerics collapsed to one hyphen, no +// leading/trailing hyphens. +export function slugifyTitle(title: string): string { + return title + .toLowerCase() + .normalize('NFKD') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +// Public URL segment for an object: a readable slugified title with the object +// id appended. The id keeps two objects that share a title (or a title that +// slugifies to nothing) addressable and unique, and makes the slug reversible — +// `objectIdFromSlug` reads it back without needing the title to be unchanged. +export function objectSlug(object: IObject): string { + const base = slugifyTitle(object.attributes.title || ''); + return base ? `${base}-${object.id}` : String(object.id); +} + +// Recover the object id from a slug produced by `objectSlug`. Matches on the +// trailing `-` (or a bare numeric slug) so a title edit never breaks an +// already-open object URL — the id is the stable part. +export function objectIdFromSlug(slug: string | undefined): number | null { + if (!slug) return null; + const match = /-(\d+)$/.exec(slug) ?? /^(\d+)$/.exec(slug); + return match ? Number(match[1]) : null; +} diff --git a/src/local-types/library/library.ts b/src/local-types/library/library.ts index 0c294e69..3dbc8360 100644 --- a/src/local-types/library/library.ts +++ b/src/local-types/library/library.ts @@ -30,10 +30,32 @@ export interface StrapiAvatarField { export interface StrapiUserRelation { data: { id: number; - attributes: { username: string }; + // Backend restricts the populated owner to a public allowlist on + // /api/libraries — only username, name, and picture come back here. + attributes: { username: string; name?: string; picture?: string }; } | null; } +/** + * Owner profile of the library currently being viewed, published to + * GlobalState for the Sidebar's Author panel. Sourced from the populated + * `user` relation (public allowlist above) plus the library's own `aboutMe`. + */ +export interface LibraryOwner { + /** Numeric id of the owning account from the populated `user` relation. + * The reliable signal for "is this my library" — compare to accountData.id + * instead of matching usernames, which the public role can't always read. */ + id?: number; + username?: string; + name?: string; + /** Account OAuth photo from the populated `user` relation (auth role only). */ + picture?: string; + /** The library's own uploaded avatar — readable by the public role, so this + * is what a logged-out visitor sees. Raw Strapi URL; resolve before use. */ + avatar?: string; + aboutMe?: string; +} + export interface StrapiLibraryDetailsComponent { id: number; aboutLibrary: string; diff --git a/src/local-types/library/shareLink.ts b/src/local-types/library/shareLink.ts new file mode 100644 index 00000000..efb7a3d5 --- /dev/null +++ b/src/local-types/library/shareLink.ts @@ -0,0 +1,38 @@ +import type { IObject } from '@local-types/library/object'; + +// Owner-side request to mint a share link. The backend reads `objectIds` (and +// optional `shelfIds`), expands them, caps the total at 21, and rejects +// non-public items. We only send `objectIds` — never shelf ids: the backend +// appends a shelf's contents after the explicit ids, which would fight the +// owner-chosen sequence. +export interface ICreateShareLinkPayload { + objectIds: number[]; +} + +// Normalized result of a successful POST /api/share-links. The raw Strapi +// response shape is wrapper-dependent, so `createShareLink` flattens it down to +// the one field the UI needs: the opaque token that addresses the link. +export interface IShareLinkResult { + token: string; +} + +// Recipient-side outcome of opening a share link by token. The backend tells +// expired (410) and unknown/bad token (404 or 400) apart from a transport/parse +// failure, and the recipient UI shows a distinct message for each: +// ok — render the shared objects in array order +// expired — 410, the 7-day TTL elapsed +// notFound — 404, the token doesn't address a live link +// invalid — 400, the token is malformed +// error — network/parse failure (retryable) +export type ShareLinkStatus = + | 'ok' + | 'expired' + | 'notFound' + | 'invalid' + | 'error'; + +export interface IShareLinkView { + status: ShareLinkStatus; + // Populated only when status === 'ok'; ordered as the owner sequenced them. + objects: IObject[]; +} diff --git a/src/pages/library/[username].tsx b/src/pages/library/[username].tsx deleted file mode 100644 index d4f5ce5f..00000000 --- a/src/pages/library/[username].tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { GetServerSideProps, NextPage } from 'next'; - -import { isLibraryEnabled } from '@constants/library/common'; -import { DEFAULT_SEO } from '@constants/library/seo.config'; - -import { AuthProvider } from '@components/Context/library/AuthContext'; -import { DashboardProvider } from '@components/Context/library/DashboardContext'; -import { GlobalStateProvider } from '@components/Context/library/GlobalStateContext'; -import { Sidebar } from '@components/library/organisms/Sidebar'; -import SeoGenerator from '@components/SeoGenerator'; - -import { LibraryTemplate } from '@layouts/library/Library'; - -import styles from './library.module.scss'; - -type LibraryPageProps = { - username: string; -}; - -const LibraryPage: NextPage = ({ username }) => { - const pageTitle = `${username} | ${DEFAULT_SEO.siteName}`; - - return ( - - - - -
-
- -
- -
-
-
-
- ); -}; - -export default LibraryPage; - -export const getServerSideProps: GetServerSideProps< - LibraryPageProps -> = async context => { - if (!isLibraryEnabled()) { - return { notFound: true }; - } - - const username = String(context.params?.username ?? ''); - - return { - props: { username }, - }; -}; diff --git a/src/pages/library/[username]/[[...object]].tsx b/src/pages/library/[username]/[[...object]].tsx new file mode 100644 index 00000000..aa8662e1 --- /dev/null +++ b/src/pages/library/[username]/[[...object]].tsx @@ -0,0 +1,82 @@ +import type { GetServerSideProps, NextPage } from 'next'; + +import { isLibraryEnabled } from '@constants/library/common'; +import { DEFAULT_SEO } from '@constants/library/seo.config'; + +import { AuthProvider } from '@components/Context/library/AuthContext'; +import { DashboardProvider } from '@components/Context/library/DashboardContext'; +import { GlobalStateProvider } from '@components/Context/library/GlobalStateContext'; +import { ShareSelectionProvider } from '@components/Context/library/ShareSelectionContext'; +import { Sidebar } from '@components/library/organisms/Sidebar'; +import SeoGenerator from '@components/SeoGenerator'; + +import { LibraryTemplate } from '@layouts/library/Library'; + +import styles from '../library.module.scss'; + +type LibraryPageProps = { + username: string; +}; + +// Optional catch-all so the library and a single object share one page module: +// `/library/[username]` (object undefined) and `/library/[username]/[slug]` +// (object = [slug]) both render here. That lets an object open via shallow +// `router.push` — the URL changes and the overview modal opens over the same, +// already-loaded library instead of a full navigation that would refetch and +// flash. The slug is read off the router inside the shelf, so this page only +// needs the username. `share/[token]` is a literal sibling and still wins for +// `/library/[username]/share/...`. +const LibraryPage: NextPage = ({ username }) => { + const pageTitle = `${username} | ${DEFAULT_SEO.siteName}`; + + return ( + + + + + +
+
+ +
+ +
+
+
+
+
+ ); +}; + +export default LibraryPage; + +export const getServerSideProps: GetServerSideProps< + LibraryPageProps +> = async context => { + if (!isLibraryEnabled()) { + return { notFound: true }; + } + + const username = String(context.params?.username ?? ''); + + return { + props: { username }, + }; +}; diff --git a/src/pages/library/[username]/share/[token].tsx b/src/pages/library/[username]/share/[token].tsx new file mode 100644 index 00000000..e0400104 --- /dev/null +++ b/src/pages/library/[username]/share/[token].tsx @@ -0,0 +1,219 @@ +import type { GetServerSideProps, NextPage } from 'next'; +import React, { JSX, useEffect, useState } from 'react'; + +import { isLibraryEnabled } from '@constants/library/common'; +import { DEFAULT_SEO } from '@constants/library/seo.config'; + +import type { + IShareLinkView, + ShareLinkStatus, +} from '@local-types/library/shareLink'; + +import { getShareLink } from '@api/library/getShareLink'; + +import { AuthProvider } from '@components/Context/library/AuthContext'; +import { DashboardProvider } from '@components/Context/library/DashboardContext'; +import { GlobalStateProvider } from '@components/Context/library/GlobalStateContext'; +import { ShareSelectionProvider } from '@components/Context/library/ShareSelectionContext'; +import { Text, TypographyVariant } from '@components/library/atoms/Text'; +import { + Button, + ButtonSize, + ButtonType, +} from '@components/library/molecules/Button'; +import { Modal, useModalClose } from '@components/library/molecules/Modal'; +import { SharedWithYouModal } from '@components/library/molecules/SharedWithYouModal'; +import { ShareSelectionPanel } from '@components/library/organisms/ShareSelectionPanel'; +import { Sidebar } from '@components/library/organisms/Sidebar'; +import SeoGenerator from '@components/SeoGenerator'; + +import { LibraryTemplate } from '@layouts/library/Library'; + +import pageStyles from '../../library.module.scss'; +import styles from './share.module.scss'; + +type SharePageProps = { + username: string; + token: string; +}; + +// Copy for the non-ok outcomes. `error` is the retryable transport/parse case; +// the rest map to the backend's 410/404/400 responses. +const ERROR_COPY: Record< + Exclude, + { title: string; text: string } +> = { + expired: { + title: 'This link has expired', + text: 'Share links are valid for 7 days. Ask the owner to send you a fresh one.', + }, + notFound: { + title: 'This link is no longer available', + text: 'The selection behind this link can’t be found. It may have been revoked.', + }, + invalid: { + title: 'This link looks broken', + text: 'The link couldn’t be read. Double-check that you copied the whole URL.', + }, + error: { + title: 'Something went wrong', + text: 'We couldn’t load this selection. Check your connection and try again.', + }, +}; + +function ShareLinkErrorModal({ + status, + onClose, +}: { + status: Exclude; + onClose: () => void; +}): JSX.Element { + const { closeRef, close } = useModalClose(onClose); + const { title, text } = ERROR_COPY[status]; + + return ( + +
+ + {title} + + + {text} + +
+
+ ); +} + +function ShareRecipientView({ username, token }: SharePageProps): JSX.Element { + const [view, setView] = useState(null); + const [introOpen, setIntroOpen] = useState(false); + const [panelOpen, setPanelOpen] = useState(false); + const [errorDismissed, setErrorDismissed] = useState(false); + + useEffect(() => { + let active = true; + void getShareLink(token).then(result => { + if (!active) return; + setView(result); + // Only the happy path leads with the intro modal; errors get their own. + if (result.status === 'ok' && result.objects.length > 0) { + setIntroOpen(true); + } + }); + return () => { + active = false; + }; + }, [token]); + + const showError = + !!view && view.status !== 'ok' && !errorDismissed + ? (view.status as Exclude) + : null; + + return ( + <> + {introOpen && view?.status === 'ok' && ( + setIntroOpen(false)} + onViewSelection={() => { + setIntroOpen(false); + setPanelOpen(true); + }} + /> + )} + + {panelOpen && view?.status === 'ok' && view.objects.length > 0 && ( + + )} + + {showError && ( + setErrorDismissed(true)} + /> + )} + + ); +} + +const SharePage: NextPage = ({ username, token }) => { + const pageTitle = `${username} | ${DEFAULT_SEO.siteName}`; + + return ( + + + + + +
+
+ +
+ +
+ +
+
+
+
+ ); +}; + +export default SharePage; + +export const getServerSideProps: GetServerSideProps< + SharePageProps +> = async context => { + if (!isLibraryEnabled()) { + return { notFound: true }; + } + + const username = String(context.params?.username ?? ''); + const token = String(context.params?.token ?? ''); + + if (!token) { + return { notFound: true }; + } + + return { + props: { username, token }, + }; +}; diff --git a/src/pages/library/[username]/share/share.module.scss b/src/pages/library/[username]/share/share.module.scss new file mode 100644 index 00000000..38f20d44 --- /dev/null +++ b/src/pages/library/[username]/share/share.module.scss @@ -0,0 +1,28 @@ +.errorModal { + width: 100% !important; + max-width: 420px !important; + background-color: var(--white) !important; +} + +.errorBody { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 40px 32px 32px; + text-align: center; +} + +.errorTitle { + color: var(--gray-darkest); +} + +.errorText { + max-width: 320px; + color: var(--black-transparent-300); +} + +.errorAction { + margin-top: 8px; + width: auto !important; +}