Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/api/library/createShareLink.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const data = b.data as Record<string, unknown> | undefined;
const attributes = data?.attributes as Record<string, unknown> | 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<IShareLinkResult | null> => {
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;
}
};
58 changes: 58 additions & 0 deletions src/api/library/getShareLink.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
const data = b.data as Record<string, unknown> | 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<string, unknown>;
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<string, unknown> | 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<IShareLinkView> => {
try {
const { data } = await axiosInstance.get(
`/api/share-links/${encodeURIComponent(token)}`,
);
const objects = extractObjects(data)
.map(mapSharedObject)
.filter((o): o is NonNullable<typeof o> => 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: [] };
}
};
3 changes: 3 additions & 0 deletions src/assets/icons/library/svg/chevron-up.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/assets/icons/library/svg/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,6 +36,7 @@ export {
BookShadowIcon,
CalendarIcon,
CheckIcon,
ChevronUpIcon,
CloseIcon,
CompanyIcon,
CopyIcon,
Expand Down
26 changes: 26 additions & 0 deletions src/components/Context/library/GlobalStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
} from 'react';

import type {
ILibrary,
LibraryOwner,
StrapiLibrariesResponse,
StrapiSingleShelfEntry,
} from '@local-types/library/library';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +84,8 @@ export function GlobalStateProvider({ children }: { children: ReactNode }) {
const [currentShelves, setCurrentShelves] = useState<
StrapiSingleShelfEntry[]
>([]);
const [currentOwner, setCurrentOwner] = useState<LibraryOwner | null>(null);
const [currentLibrary, setCurrentLibrary] = useState<ILibrary | null>(null);
const [isCreateBlocked, setIsCreateBlocked] = useState(false);
const didAttemptUserLoad = useRef(false);
const didAttemptLibrariesLoad = useRef(false);
Expand Down Expand Up @@ -132,6 +150,10 @@ export function GlobalStateProvider({ children }: { children: ReactNode }) {
refetchLibraries,
currentShelves,
setCurrentShelves,
currentOwner,
setCurrentOwner,
currentLibrary,
setCurrentLibrary,
isCreateBlocked,
setIsCreateBlocked,
}),
Expand All @@ -146,6 +168,10 @@ export function GlobalStateProvider({ children }: { children: ReactNode }) {
refetchLibraries,
currentShelves,
setCurrentShelves,
currentOwner,
setCurrentOwner,
currentLibrary,
setCurrentLibrary,
isCreateBlocked,
setIsCreateBlocked,
],
Expand Down
127 changes: 127 additions & 0 deletions src/components/Context/library/ShareSelectionContext.tsx
Original file line number Diff line number Diff line change
@@ -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<IObject[]>([]);

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 (
<ShareSelectionContext.Provider value={value}>
{children}
</ShareSelectionContext.Provider>
);
}

export function useShareSelection(): ShareSelectionContextValue {
const context = useContext(ShareSelectionContext);

if (!context) {
throw new Error(
'useShareSelection must be used within a ShareSelectionProvider',
);
}

return context;
}
12 changes: 12 additions & 0 deletions src/components/library/molecules/AudioCard/AudioCard.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
14 changes: 12 additions & 2 deletions src/components/library/molecules/AudioCard/AudioCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export function AudioCard({
className,
selected = false,
onSelectToggle,
selectDisabled = false,
compact = false,
}: AudioCardProps): JSX.Element {
const { attributes } = object;
const coverUrl = resolveStrapiUrl(
Expand All @@ -33,7 +35,11 @@ export function AudioCard({
};

return (
<div className={classNames(styles.row, className)}>
<div
className={classNames(styles.row, className, {
[styles.compact]: compact,
})}
>
<div
className={classNames(styles.card, { [styles.selected]: selected })}
role="button"
Expand All @@ -44,7 +50,11 @@ export function AudioCard({
>
{onSelectToggle && (
<div className={styles.select}>
<SelectToggle selected={selected} onToggle={onSelectToggle} />
<SelectToggle
selected={selected}
onToggle={onSelectToggle}
disabled={selectDisabled && !selected}
/>
</div>
)}
<div className={styles.cover}>
Expand Down
5 changes: 5 additions & 0 deletions src/components/library/molecules/AudioCard/AudioCard.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading