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
2 changes: 2 additions & 0 deletions src/components/library/atoms/Avatar/Avatar.module.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.avatar {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
Expand All @@ -8,6 +9,7 @@
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}

Expand Down
7 changes: 6 additions & 1 deletion src/components/library/atoms/Avatar/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ export function Avatar(props: AvatarProps): JSX.Element {
return (
<div className={classNames(className, styles.avatar)}>
{url ? (
<Image src={url} width={48} height={48} alt="Picture of the author" />
<Image
src={url}
fill
sizes="(max-width: 590px) 100px, 208px"
alt="Picture of the author"
/>
) : (
<AvatarIcon />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { resolveStrapiUrl } from '@utils/library/resolveStrapiUrl';
import classNames from 'classnames';
import React, { JSX, useCallback, useMemo, useState } from 'react';

import { SHELF_FULL_MESSAGE } from '@constants/library/common';
import { KEEPSIMPLE_URL, SHELF_FULL_MESSAGE } from '@constants/library/common';

import type {
Difficulty,
Expand All @@ -12,6 +12,7 @@ import type {

import { useClickOutside } from '@hooks/library/useClickOutside';

import { objectSlug } from '@lib/library/objectSlug';
import { isShelfFullError } from '@lib/library/shelfFull';
import { sanitizeHtml } from '@lib/sanitizeHtml';

Expand Down Expand Up @@ -51,6 +52,10 @@ import type { ObjectOverviewModalProps } from './ObjectOverviewModal.types';

import styles from './ObjectOverviewModal.module.scss';

// Canonical public host for shareable links — env-driven in staging/prod,
// falling back to the production domain (mirrors ShareSelectionPanel).
const SHARE_BASE_URL = process.env.NEXT_PUBLIC_DOMAIN ?? KEEPSIMPLE_URL;

function formatDate(iso?: string): string | null {
if (!iso) return null;
const d = new Date(iso);
Expand Down Expand Up @@ -112,6 +117,8 @@ export function ObjectOverviewModal(
const [moveLoading, setMoveLoading] = useState(false);
const [moveError, setMoveError] = useState<string | null>(null);

const [shareCopied, setShareCopied] = useState(false);

const { currentShelves } = useGlobalState();

const closeMenu = useCallback(() => setMenuOpen(false), []);
Expand Down Expand Up @@ -154,10 +161,24 @@ export function ObjectOverviewModal(
}
};

const handleShare = () => {
// TODO: Implement Share when sharing route + tokens exist.
// Likely: copy a public URL to clipboard like /share/objects/<slug or shareToken>.
// Depends on a public-read endpoint or signed URL from backend.
const handleShare = async () => {
const url = `${SHARE_BASE_URL}/library/${ownerUsername}/${objectSlug(object)}`;
try {
await navigator.clipboard.writeText(url);
} catch {
// Clipboard API is unavailable on insecure origins / older browsers —
// fall back to a throwaway textarea + execCommand so copy still works.
const textarea = document.createElement('textarea');
textarea.value = url;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setShareCopied(true);
setTimeout(() => setShareCopied(false), 2000);
};

// Fall back to `defaultShelfId` (the shelf this object is rendered under):
Expand Down Expand Up @@ -347,8 +368,8 @@ export function ObjectOverviewModal(
<Button
type={ButtonType.Primary}
size={ButtonSize.Default}
label="Share"
ariaLabel="Share"
label={shareCopied ? 'Copied' : 'Share'}
ariaLabel={shareCopied ? 'Link copied' : 'Share'}
Icon={<ShareIcon />}
onClick={handleShare}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,20 @@ import styles from './ShareSelectionPanel.module.scss';

const SHARE_BASE_URL = process.env.NEXT_PUBLIC_DOMAIN ?? KEEPSIMPLE_URL;

function ObjectCard({ object }: { object: IObject }): JSX.Element {
function ObjectCard({
object,
onClick,
}: {
object: IObject;
onClick?: (object: IObject) => void;
}): JSX.Element {
switch (object.attributes.type) {
case 'video':
return <VideoCard object={object} compact />;
return <VideoCard object={object} onClick={onClick} compact />;
case 'audio':
return <AudioCard object={object} compact />;
return <AudioCard object={object} onClick={onClick} compact />;
default:
return <BookCard object={object} compact />;
return <BookCard object={object} onClick={onClick} compact />;
}
}

Expand All @@ -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,
Expand Down Expand Up @@ -100,15 +107,19 @@ function SortableItem(props: {
</button>
)}

{/* 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. */}
<div
className={classNames(styles.cardHandle, {
[styles.draggable]: !readOnly,
})}
{...(readOnly ? {} : { ...attributes, ...listeners })}
>
<ObjectCard object={object} />
<ObjectCard
object={object}
onClick={readOnly ? onObjectClick : undefined}
/>
</div>

{!readOnly && sequence}
Expand All @@ -124,6 +135,7 @@ export function ShareSelectionPanel({
onReorder,
onRemove,
onClear,
onObjectClick,
className,
}: ShareSelectionPanelProps): JSX.Element | null {
const [collapsed, setCollapsed] = useState(false);
Expand Down Expand Up @@ -274,6 +286,7 @@ export function ShareSelectionPanel({
position={index + 1}
readOnly={readOnly}
onRemove={setPendingRemoveId}
onObjectClick={onObjectClick}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
13 changes: 13 additions & 0 deletions src/pages/library/[username]/share/[token].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { JSX, useEffect, useState } from 'react';
import { isLibraryEnabled } from '@constants/library/common';
import { DEFAULT_SEO } from '@constants/library/seo.config';

import type { IObject } from '@local-types/library/object';
import type {
IShareLinkView,
ShareLinkStatus,
Expand All @@ -23,6 +24,7 @@ import {
} from '@components/library/molecules/Button';
import { Modal, useModalClose } from '@components/library/molecules/Modal';
import { SharedWithYouModal } from '@components/library/molecules/SharedWithYouModal';
import { ObjectOverviewModal } from '@components/library/organisms/ObjectOverviewModal';
import { ShareSelectionPanel } from '@components/library/organisms/ShareSelectionPanel';
import { Sidebar } from '@components/library/organisms/Sidebar';
import SeoGenerator from '@components/SeoGenerator';
Expand Down Expand Up @@ -104,6 +106,7 @@ function ShareRecipientView({ username, token }: SharePageProps): JSX.Element {
const [introOpen, setIntroOpen] = useState(false);
const [panelOpen, setPanelOpen] = useState(false);
const [errorDismissed, setErrorDismissed] = useState(false);
const [activeObject, setActiveObject] = useState<IObject | null>(null);

useEffect(() => {
let active = true;
Expand Down Expand Up @@ -144,6 +147,16 @@ function ShareRecipientView({ username, token }: SharePageProps): JSX.Element {
objects={view.objects}
ownerUsername={username}
readOnly
onObjectClick={setActiveObject}
/>
)}

{activeObject && (
<ObjectOverviewModal
object={activeObject}
isOwner={false}
ownerUsername={username}
onClose={() => setActiveObject(null)}
/>
)}

Expand Down
12 changes: 8 additions & 4 deletions src/utils/library/mapStrapiLibraries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,19 @@ export function mapStrapiLibraryEntryToCard(
);
const aboutMePlain = stripHtml(attributes.aboutMe);

const libraryName =
attributes.name?.trim() || aboutLibraryPlain || `Library ${id}`;
const username = attributes.user?.data?.attributes?.username;

// Libraries are owner-scoped, so present them as "<owner>'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 description = aboutMePlain || aboutLibraryPlain;

const avatarUrl = attributes.avatar?.data?.attributes?.url;

const username = attributes.user?.data?.attributes?.username;

return {
id,
username,
Expand Down
Loading