Skip to content

Commit befe767

Browse files
Copilotedwardzjl
andauthored
Implement "My shares" functionality with compact horizontal layout, clickable URLs, and cursor-based pagination (#1347)
* Initial plan * Implement My shares functionality with complete UI and navigation Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Address PR feedback: implement React Router loader, use formatTimestamp, replace MUI Box with HTML elements Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Remove all MUI dependencies from sharing component and package-lock.json Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Revert unnecessary yarn.lock and .gitignore changes Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Fix scrolling issue in sharing page by adding overflow-y: auto Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Fix scrollbar positioning and apply scroll-box class to sharing page Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Remove package-lock.json, revert yarn.lock, and add pagination support to shares page Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Update pagination to use cursor-based API and create reusable Pagination component Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Address PR feedback: add blank line at EOF in Pagination component and remove backward compatibility check Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Add ChatboxHeader and remove scrolling feature for paginated view Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * Make shares list more compact with horizontal layout and clickable URLs Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> * chore(web): move background color to sharing-container * feat(web): make sharing page more responsive * chore(web): polish pagination info styling * refactor(web): use module css for pagination component * refactor(web): use module css for sharing route * feat(web): align sharing title, timestamp and url to left * chore(web): adapt cursor pagination * fix(web): dynamic page size to avoid overflow * fix(web): fix sharing navigation * refactor(web): refactor sharing * fix(web): fix dynamic share page size * fix(web): add prop types support * styling(web): reformat code * chore(web): polish share styling * refactor(web): refactor sharing page * refactor(web): use infinite scroll for sharings * perf(web): use client route to navigate to my sharings * test(web): fix unittests --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: edwardzjl <7287580+edwardzjl@users.noreply.github.com> Co-authored-by: Junlin Zhou <jameszhou2108@hotmail.com>
1 parent a5ba558 commit befe767

File tree

9 files changed

+610
-7
lines changed

9 files changed

+610
-7
lines changed

web/src/App.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const router = createBrowserRouter([
4545
{
4646
path: "sharing",
4747
element: <Sharing />,
48+
loader: Sharing.loader,
4849
},
4950
]
5051
},

web/src/components/UserMenu/index.jsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import styles from "./index.module.css";
22

3+
import { useNavigate } from "react-router-dom";
34
import Avatar from "@mui/material/Avatar";
5+
import ShareIcon from "@mui/icons-material/Share";
46

57
import { Dropdown, DropdownButton, DropdownMenu } from "@/components/DropdownMenu";
68
import { useUserProfile } from "@/contexts/user/hook";
79

810

911
const UserMenu = () => {
1012
const { username, avatar } = useUserProfile();
13+
const navigate = useNavigate();
1114

1215
const handleLogout = async (e) => {
1316
e.preventDefault();
@@ -16,6 +19,10 @@ const UserMenu = () => {
1619
window.location.href = "/oauth2/sign_out";
1720
};
1821

22+
const handleMyShares = () => {
23+
navigate("/sharing");
24+
};
25+
1926
return (
2027
<Dropdown>
2128
<DropdownButton className={styles.userInfoMenu}>
@@ -32,6 +39,16 @@ const UserMenu = () => {
3239
<DropdownMenu className={styles.userInfoMenuList}>
3340
<li><span>{username}</span></li>
3441
<hr className={styles.userInfoMenuUsernameHr} />
42+
<li>
43+
<button
44+
className={styles.userInfoMenuItem}
45+
onClick={handleMyShares}
46+
aria-label="My Shares"
47+
>
48+
<ShareIcon sx={{ fontSize: 16, marginRight: 1 }} />
49+
<span className={styles.themeMenuText}>My Shares</span>
50+
</button>
51+
</li>
3552
<li>
3653
<button
3754
className={styles.userInfoMenuItem}

web/src/components/UserMenu/index.test.jsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { render, screen, fireEvent } from "@testing-library/react";
22
import { describe, it, expect } from "vitest";
3+
import { BrowserRouter } from "react-router-dom";
34

45
import { UserContext } from "@/contexts/user";
56

67
import UserMenu from "./index";
78

89
const setup = () => render(
9-
<UserContext.Provider value={mockUserContextValue}>
10-
<UserMenu />
11-
</UserContext.Provider>
10+
<BrowserRouter>
11+
<UserContext.Provider value={mockUserContextValue}>
12+
<UserMenu />
13+
</UserContext.Provider>
14+
</BrowserRouter>
1215
);
1316

1417
const mockUserContextValue = {

web/src/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@import "open-props/style";
22
@import "open-props/gray-hsl.min.css";
3+
@import "material-icons/iconfont/material-icons.css";
34

45
:root {
56
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import styles from "../index.module.css";
2+
3+
4+
const EmptyState = () => {
5+
return (
6+
<section className={styles.emptyState}>
7+
<p className={styles.emptyStateText}>
8+
You haven&apos;t shared any conversations yet.
9+
</p>
10+
<p className={styles.emptyStateSubtext}>
11+
Share a conversation to see it listed here.
12+
</p>
13+
</section>
14+
);
15+
};
16+
17+
export default EmptyState;
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import styles from "../index.module.css";
2+
3+
import { memo } from "react";
4+
import PropTypes from "prop-types";
5+
import Icon from "@mui/material/Icon";
6+
7+
import { formatTimestamp } from "@/commons";
8+
9+
const ShareCard = memo(({ share, onCopy, onDelete }) => {
10+
return (
11+
<div className={styles.shareCard} data-list-item>
12+
<div className={styles.shareCardContent}>
13+
<h2 className={styles.shareTitle}>
14+
{share.title}
15+
</h2>
16+
<p className={styles.shareMeta}>
17+
Created: {formatTimestamp(share.created_at)}
18+
</p>
19+
<a
20+
href={share.url}
21+
target="_blank"
22+
rel="noopener noreferrer"
23+
className={styles.shareUrl}
24+
>
25+
{share.url}
26+
</a>
27+
</div>
28+
<div className={styles.shareCardActions}>
29+
<button
30+
className={styles.actionButton}
31+
onClick={() => onCopy(share.url)}
32+
aria-label="Copy share URL"
33+
title="Copy share URL"
34+
>
35+
<Icon baseClassName="material-symbols-outlined">content_copy</Icon>
36+
</button>
37+
<button
38+
className={`${styles.actionButton} ${styles.actionButtonDanger}`}
39+
onClick={() => onDelete(share.id)}
40+
aria-label="Delete share"
41+
title="Delete share"
42+
>
43+
<Icon baseClassName="material-symbols-outlined">delete</Icon>
44+
</button>
45+
</div>
46+
</div>
47+
);
48+
});
49+
50+
ShareCard.propTypes = {
51+
share: PropTypes.shape({
52+
id: PropTypes.string.isRequired, // UUID string
53+
title: PropTypes.string.isRequired,
54+
url: PropTypes.string.isRequired,
55+
created_at: PropTypes.oneOfType([
56+
// API returns ISO string; accept a few common timestamp forms
57+
PropTypes.string,
58+
PropTypes.number, // epoch millis
59+
PropTypes.instanceOf(Date),
60+
]).isRequired,
61+
owner: PropTypes.string, // not displayed here but part of schema
62+
messages: PropTypes.arrayOf(PropTypes.object), // messages not rendered in card list
63+
}).isRequired,
64+
onCopy: PropTypes.func.isRequired,
65+
onDelete: PropTypes.func.isRequired,
66+
};
67+
68+
69+
ShareCard.displayName = 'ShareCard';
70+
71+
export default ShareCard;

web/src/routes/sharing/index.css

Whitespace-only changes.

web/src/routes/sharing/index.jsx

Lines changed: 168 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,175 @@
1-
import "./index.css";
1+
import styles from "./index.module.css";
2+
3+
import { useState, useCallback, useRef } from "react";
4+
import { useLoaderData } from "react-router-dom";
5+
6+
import ChatboxHeader from "@/components/ChatboxHeader";
7+
import { useSnackbar } from "@/contexts/snackbar/hook";
8+
import { useInfiniteScroll } from "@/hooks/useInfiniteScroll";
9+
10+
// Local components
11+
import ShareCard from "./components/ShareCard";
12+
import EmptyState from "./components/EmptyState";
13+
14+
// API helper function
15+
const fetchShares = async (size = null, cursor = null) => {
16+
const apiUrl = new URL("/api/shares", window.location.origin);
17+
if (cursor) {
18+
apiUrl.searchParams.set("cursor", cursor);
19+
}
20+
if (size) {
21+
apiUrl.searchParams.set("size", size.toString());
22+
}
23+
24+
const response = await fetch(apiUrl.toString());
25+
if (!response.ok) {
26+
throw new Error(`Failed to fetch shares: ${response.statusText}`);
27+
}
28+
return response.json();
29+
};
30+
31+
async function loader() {
32+
try {
33+
const data = await fetchShares();
34+
return {
35+
shares: data.items || [],
36+
nextCursor: data.next_page || null,
37+
};
38+
} catch (error) {
39+
throw new Error(`Failed to load shares: ${error.message}`);
40+
}
41+
}
242

343
const Sharing = () => {
44+
const loaderData = useLoaderData();
45+
const { shares: initialShares, nextCursor: initialCursor } = loaderData;
46+
47+
// State
48+
const [shares, setShares] = useState(initialShares);
49+
const [nextCursor, setNextCursor] = useState(initialCursor);
50+
const [isLoading, setIsLoading] = useState(false);
51+
const loadMoreRef = useRef();
52+
53+
// Hooks
54+
const { setSnackbar } = useSnackbar();
55+
56+
// Memoized values
57+
const hasShares = shares.length > 0;
58+
const hasMore = !!nextCursor;
59+
60+
// Fetch more shares for infinite scrolling
61+
const fetchMoreShares = useCallback(async () => {
62+
if (isLoading || !hasMore) {
63+
return;
64+
}
65+
66+
setIsLoading(true);
67+
try {
68+
const data = await fetchShares(20, nextCursor);
69+
setShares(current => [...current, ...(data.items || [])]);
70+
setNextCursor(data.next_page || null);
71+
} catch (err) {
72+
setSnackbar({
73+
open: true,
74+
message: `Error loading more shares: ${err.message}`,
75+
severity: "error",
76+
});
77+
} finally {
78+
setIsLoading(false);
79+
}
80+
}, [nextCursor, isLoading, hasMore, setSnackbar]);
81+
82+
// Set up infinite scroll
83+
useInfiniteScroll({
84+
targetRef: loadMoreRef,
85+
onLoadMore: fetchMoreShares,
86+
isLoading,
87+
hasMore,
88+
});
89+
90+
// Share actions
91+
const deleteShare = useCallback(async (shareId) => {
92+
try {
93+
const response = await fetch(`/api/shares/${shareId}`, {
94+
method: "DELETE",
95+
});
96+
97+
if (response.ok) {
98+
setShares(current => current.filter(share => share.id !== shareId));
99+
setSnackbar({
100+
open: true,
101+
message: "Share deleted successfully",
102+
severity: "success",
103+
});
104+
} else {
105+
throw new Error(`Failed to delete share: ${response.statusText}`);
106+
}
107+
} catch (err) {
108+
setSnackbar({
109+
open: true,
110+
message: `Error deleting share: ${err.message}`,
111+
severity: "error",
112+
});
113+
}
114+
}, [setSnackbar]);
115+
116+
const copyToClipboard = useCallback(async (url) => {
117+
try {
118+
await navigator.clipboard.writeText(url);
119+
setSnackbar({
120+
open: true,
121+
message: "Share URL copied to clipboard",
122+
severity: "success",
123+
});
124+
} catch {
125+
setSnackbar({
126+
open: true,
127+
message: "Failed to copy URL",
128+
severity: "error",
129+
});
130+
}
131+
}, [setSnackbar]);
132+
4133
return (
5-
<>
6-
<h1>Sharing</h1>
7-
</>
134+
<div className={`${styles.sharingContainer} scroll-box`}>
135+
<ChatboxHeader />
136+
<div className={styles.sharingContent}>
137+
<h1 className={styles.sharingTitle}>My Shares</h1>
138+
<p className={styles.sharingInfo}>
139+
Your shared links will remain publicly accessible as long as the related conversation
140+
is still saved in your chat history. If any part of the conversation is deleted,
141+
its public link will also be removed. When you delete a link, the corresponding
142+
conversation in your chat history is not deleted, nor is any content you may have
143+
posted on other websites.
144+
</p>
145+
146+
{!hasShares ? (
147+
<EmptyState />
148+
) : (
149+
<div className={styles.sharesList}>
150+
{shares.map((share) => (
151+
<ShareCard
152+
key={share.id}
153+
share={share}
154+
onCopy={copyToClipboard}
155+
onDelete={deleteShare}
156+
/>
157+
))}
158+
159+
{/* Infinite scroll anchor */}
160+
<div ref={loadMoreRef} className={styles.loadMoreAnchor}>
161+
{isLoading ? (
162+
<div className={styles.spinner} />
163+
) : (
164+
<div style={{ width: 24, height: 24, visibility: "hidden" }} />
165+
)}
166+
</div>
167+
</div>
168+
)}
169+
</div>
170+
</div>
8171
);
9172
};
10173

11174
export default Sharing;
175+
Sharing.loader = loader;

0 commit comments

Comments
 (0)