diff --git a/src/components/library/atoms/Avatar/Avatar.module.scss b/src/components/library/atoms/Avatar/Avatar.module.scss
index 8f5fb435..0adedc85 100644
--- a/src/components/library/atoms/Avatar/Avatar.module.scss
+++ b/src/components/library/atoms/Avatar/Avatar.module.scss
@@ -1,4 +1,5 @@
.avatar {
+ position: relative;
width: 100%;
height: 100%;
overflow: hidden;
@@ -8,6 +9,7 @@
img {
width: 100%;
height: 100%;
+ object-fit: cover;
}
}
diff --git a/src/components/library/atoms/Avatar/Avatar.tsx b/src/components/library/atoms/Avatar/Avatar.tsx
index 7887c24e..d9915d54 100644
--- a/src/components/library/atoms/Avatar/Avatar.tsx
+++ b/src/components/library/atoms/Avatar/Avatar.tsx
@@ -14,7 +14,12 @@ export function Avatar(props: AvatarProps): JSX.Element {
return (
{url ? (
-
+
) : (
)}
diff --git a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx
index 0d4bfe07..d1945e17 100644
--- a/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx
+++ b/src/components/library/organisms/ObjectOverviewModal/ObjectOverviewModal.tsx
@@ -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,
@@ -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';
@@ -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);
@@ -112,6 +117,8 @@ export function ObjectOverviewModal(
const [moveLoading, setMoveLoading] = useState(false);
const [moveError, setMoveError] = useState
(null);
+ const [shareCopied, setShareCopied] = useState(false);
+
const { currentShelves } = useGlobalState();
const closeMenu = useCallback(() => setMenuOpen(false), []);
@@ -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/.
- // 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):
@@ -347,8 +368,8 @@ export function ObjectOverviewModal(
}
onClick={handleShare}
/>
diff --git a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx
index 5617eb9a..f00bdcc5 100644
--- a/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx
+++ b/src/components/library/organisms/ShareSelectionPanel/ShareSelectionPanel.tsx
@@ -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 ;
+ 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);
@@ -274,6 +286,7 @@ export function ShareSelectionPanel({
position={index + 1}
readOnly={readOnly}
onRemove={setPendingRemoveId}
+ onObjectClick={onObjectClick}
/>
))}
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/pages/library/[username]/share/[token].tsx b/src/pages/library/[username]/share/[token].tsx
index e0400104..d50aa323 100644
--- a/src/pages/library/[username]/share/[token].tsx
+++ b/src/pages/library/[username]/share/[token].tsx
@@ -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,
@@ -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';
@@ -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(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..9b16af3c 100644
--- a/src/utils/library/mapStrapiLibraries.ts
+++ b/src/utils/library/mapStrapiLibraries.ts
@@ -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 "'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,