From 1e5e49c1a2163a60ab1fcccc8e564101899dd964 Mon Sep 17 00:00:00 2001 From: memoclaw <265580040+memoclaw@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:39:09 +0800 Subject: [PATCH] refactor(web): consolidate SharedMemo into MemoDetail Merge the SharedMemo page into MemoDetail so both /memos/:uid and /memos/shares/:token routes use the same component. MemoDetail now detects share mode via route params, fetches the memo with useSharedMemo, silences error redirects, and rewrites attachment URLs with the share token. The full detail experience (comments, sidebar, parent link) is available on shared links. - Delete web/src/pages/SharedMemo.tsx - Update MemoDetail to handle both direct and share-token fetching - Move withShareAttachmentLinks helper to useMemoShareQueries - Fix isInMemoDetailPage check for /memos/shares/ routes - Update router to render MemoDetail on the share route --- web/src/components/MemoView/MemoView.tsx | 2 +- .../components/MemoView/MemoViewContext.tsx | 2 +- web/src/hooks/useMemoDetailError.ts | 18 +--- web/src/hooks/useMemoShareQueries.ts | 9 ++ web/src/pages/MemoDetail.tsx | 49 ++++++++--- web/src/pages/SharedMemo.tsx | 85 ------------------- web/src/pages/SignIn.tsx | 8 +- web/src/pages/SignUp.tsx | 8 +- web/src/router/index.tsx | 4 +- 9 files changed, 55 insertions(+), 130 deletions(-) delete mode 100644 web/src/pages/SharedMemo.tsx diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index a44fdad84..f177657ea 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -38,7 +38,7 @@ const MemoView: React.FC = (props: MemoViewProps) => { const closeEditor = useCallback(() => setShowEditor(false), []); const location = useLocation(); - const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`); + const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/"); const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0; const contextValue = useMemo( diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx index c9df07c96..d9c29ca2f 100644 --- a/web/src/components/MemoView/MemoViewContext.tsx +++ b/web/src/components/MemoView/MemoViewContext.tsx @@ -37,7 +37,7 @@ export const useMemoViewDerived = () => { const { memo, isArchived, readonly } = useMemoViewContext(); const location = useLocation(); - const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`); + const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`) || location.pathname.startsWith("/memos/shares/"); const commentAmount = computeCommentAmount(memo); const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined; diff --git a/web/src/hooks/useMemoDetailError.ts b/web/src/hooks/useMemoDetailError.ts index f1f6b7366..15a4ef291 100644 --- a/web/src/hooks/useMemoDetailError.ts +++ b/web/src/hooks/useMemoDetailError.ts @@ -2,16 +2,12 @@ import { Code, ConnectError } from "@connectrpc/connect"; import { useEffect } from "react"; import { toast } from "react-hot-toast"; import useNavigateTo from "@/hooks/useNavigateTo"; -import { AUTH_REASON_PROTECTED_MEMO, redirectOnAuthFailure } from "@/utils/auth-redirect"; interface UseMemoDetailErrorOptions { error: Error | null; - pathname: string; - search: string; - hash: string; } -const useMemoDetailError = ({ error, pathname, search, hash }: UseMemoDetailErrorOptions) => { +const useMemoDetailError = ({ error }: UseMemoDetailErrorOptions) => { const navigateTo = useNavigateTo(); useEffect(() => { @@ -20,15 +16,7 @@ const useMemoDetailError = ({ error, pathname, search, hash }: UseMemoDetailErro } if (error instanceof ConnectError) { - if (error.code === Code.Unauthenticated) { - redirectOnAuthFailure(true, { - redirect: `${pathname}${search}${hash}`, - reason: AUTH_REASON_PROTECTED_MEMO, - }); - return; - } - - if (error.code === Code.PermissionDenied || error.code === Code.NotFound) { + if (error.code === Code.Unauthenticated || error.code === Code.PermissionDenied || error.code === Code.NotFound) { navigateTo("/404", { replace: true }); return; } @@ -38,7 +26,7 @@ const useMemoDetailError = ({ error, pathname, search, hash }: UseMemoDetailErro } toast.error(error.message); - }, [error, hash, pathname, search, navigateTo]); + }, [error, navigateTo]); }; export default useMemoDetailError; diff --git a/web/src/hooks/useMemoShareQueries.ts b/web/src/hooks/useMemoShareQueries.ts index 05720652d..24d4229bd 100644 --- a/web/src/hooks/useMemoShareQueries.ts +++ b/web/src/hooks/useMemoShareQueries.ts @@ -2,6 +2,7 @@ import { create } from "@bufbuild/protobuf"; import { timestampFromDate } from "@bufbuild/protobuf/wkt"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { memoServiceClient } from "@/connect"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { MemoShare } from "@/types/proto/api/v1/memo_service_pb"; import { CreateMemoShareRequestSchema, @@ -90,3 +91,11 @@ export function getShareUrl(share: MemoShare): string { export function getShareToken(share: MemoShare): string { return share.name.split("/").pop() ?? ""; } + +/** Rewrites attachment URLs to include a share token for unauthenticated access. */ +export function withShareAttachmentLinks(attachments: Attachment[], token: string): Attachment[] { + return attachments.map((a) => { + if (a.externalLink) return a; + return { ...a, externalLink: `${window.location.origin}/file/${a.name}/${a.filename}?share_token=${encodeURIComponent(token)}` }; + }); +} diff --git a/web/src/pages/MemoDetail.tsx b/web/src/pages/MemoDetail.tsx index d346afcdd..9f1776beb 100644 --- a/web/src/pages/MemoDetail.tsx +++ b/web/src/pages/MemoDetail.tsx @@ -1,6 +1,7 @@ +import { Code, ConnectError } from "@connectrpc/connect"; import { ArrowUpLeftFromCircleIcon } from "lucide-react"; import { useEffect } from "react"; -import { Link, useLocation, useParams } from "react-router-dom"; +import { Link, Navigate, useLocation, useParams } from "react-router-dom"; import MemoCommentSection from "@/components/MemoCommentSection"; import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar"; import MemoView from "@/components/MemoView"; @@ -9,22 +10,36 @@ import { memoNamePrefix } from "@/helpers/resource-names"; import useMediaQuery from "@/hooks/useMediaQuery"; import useMemoDetailError from "@/hooks/useMemoDetailError"; import { useMemo, useMemoComments } from "@/hooks/useMemoQueries"; +import { useSharedMemo, withShareAttachmentLinks } from "@/hooks/useMemoShareQueries"; import { cn } from "@/lib/utils"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; const MemoDetail = () => { const md = useMediaQuery("md"); const params = useParams(); const location = useLocation(); const { state: locationState, hash } = location; - const memoName = `${memoNamePrefix}${params.uid}`; - const { data: memo, error, isLoading } = useMemo(memoName, { enabled: !!memoName }); + // Detect share mode from the route parameter. + const shareToken = params.token; + const isShareMode = !!shareToken; + + // Primary memo fetch — share token or direct name. + const memoNameFromParams = params.uid ? `${memoNamePrefix}${params.uid}` : ""; + const { + data: memoFromDirect, + error: directError, + isLoading: directLoading, + } = useMemo(memoNameFromParams, { enabled: !isShareMode && !!memoNameFromParams }); + const { data: memoFromShare, error: shareError, isLoading: shareLoading } = useSharedMemo(shareToken ?? "", { enabled: isShareMode }); + + const memo = isShareMode ? memoFromShare : memoFromDirect; + const error = isShareMode ? shareError : directError; + const isLoading = isShareMode ? shareLoading : directLoading; + const memoName = memo?.name ?? memoNameFromParams; useMemoDetailError({ error: error as Error | null, - pathname: location.pathname, - search: location.search, - hash: location.hash, }); const { data: parentMemo } = useMemo(memo?.parent || "", { @@ -42,15 +57,27 @@ const MemoDetail = () => { el?.scrollIntoView({ behavior: "smooth", block: "center" }); }, [hash, comments]); + if (isShareMode) { + const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated); + if (isNotFound || (!isLoading && !memo)) { + return ; + } + } + if (isLoading || !memo) { return null; } + // In share mode, rewrite attachment URLs to include the share token for unauthenticated access. + const displayMemo = isShareMode + ? { ...memo, attachments: withShareAttachmentLinks(memo.attachments as Attachment[], shareToken!) } + : memo; + return (
{!md && ( - + )}
@@ -69,19 +96,19 @@ const MemoDetail = () => {
)} - + {md && (
- +
)} diff --git a/web/src/pages/SharedMemo.tsx b/web/src/pages/SharedMemo.tsx deleted file mode 100644 index 1cdb92ea6..000000000 --- a/web/src/pages/SharedMemo.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { Timestamp } from "@bufbuild/protobuf/wkt"; -import { timestampDate } from "@bufbuild/protobuf/wkt"; -import { Code, ConnectError } from "@connectrpc/connect"; -import { AlertCircleIcon } from "lucide-react"; -import { useParams } from "react-router-dom"; -import MemoContent from "@/components/MemoContent"; -import { AttachmentListView } from "@/components/MemoMetadata"; -import { useImagePreview } from "@/components/MemoView/hooks"; -import PreviewImageDialog from "@/components/PreviewImageDialog"; -import UserAvatar from "@/components/UserAvatar"; -import { useSharedMemo } from "@/hooks/useMemoShareQueries"; -import { useUser } from "@/hooks/useUserQueries"; -import i18n from "@/i18n"; -import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; -import { useTranslate } from "@/utils/i18n"; - -function withShareAttachmentLinks(attachments: Attachment[], token: string): Attachment[] { - return attachments.map((a) => { - if (a.externalLink) return a; - return { ...a, externalLink: `${window.location.origin}/file/${a.name}/${a.filename}?share_token=${encodeURIComponent(token)}` }; - }); -} - -const SharedMemo = () => { - const t = useTranslate(); - const { token = "" } = useParams<{ token: string }>(); - const { previewState, openPreview, setPreviewOpen } = useImagePreview(); - - const { data: memo, error, isLoading } = useSharedMemo(token, { enabled: !!token }); - const { data: creator } = useUser(memo?.creator ?? "", { enabled: !!memo?.creator }); - - const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated); - - if (isLoading) { - return ( -
-
-
- ); - } - - if (isNotFound || (!isLoading && !memo)) { - return ( -
- -

{t("memo.share.invalid-link")}

-
- ); - } - - if (error || !memo) return null; - - const displayDate = (memo.displayTime as Timestamp | undefined) - ? timestampDate(memo.displayTime as Timestamp)?.toLocaleString(i18n.language) - : null; - - return ( -
- {/* Creator + date above the card */} -
-
- - {creator?.displayName || creator?.username || memo.creator} -
- {displayDate && {displayDate}} -
- -
- - {memo.attachments.length > 0 && ( - - )} -
- - -
- ); -}; - -export default SharedMemo; diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/SignIn.tsx index 8a38d71bd..de7e9f28f 100644 --- a/web/src/pages/SignIn.tsx +++ b/web/src/pages/SignIn.tsx @@ -12,7 +12,7 @@ import useCurrentUser from "@/hooks/useCurrentUser"; import { handleError } from "@/lib/error"; import { ROUTES } from "@/router/routes"; import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb"; -import { AUTH_REASON_PARAM, AUTH_REASON_PROTECTED_MEMO, AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; +import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; import { storeOAuthState } from "@/utils/oauth"; @@ -23,7 +23,6 @@ const SignIn = () => { const { generalSetting: instanceGeneralSetting } = useInstance(); const [searchParams] = useSearchParams(); const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); - const authReason = searchParams.get(AUTH_REASON_PARAM); const signUpPath = searchParams.toString() ? `${ROUTES.AUTH}/signup?${searchParams.toString()}` : `${ROUTES.AUTH}/signup`; // Redirect to root page if already signed in. @@ -87,11 +86,6 @@ const SignIn = () => {

{instanceGeneralSetting.customProfile?.title || "Memos"}

- {authReason === AUTH_REASON_PROTECTED_MEMO && ( -
- {t("auth.protected-memo-notice")} -
- )} {!instanceGeneralSetting.disallowPasswordAuth ? ( ) : ( diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx index cf02471c9..9fd6b771f 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/SignUp.tsx @@ -16,7 +16,7 @@ import useNavigateTo from "@/hooks/useNavigateTo"; import { handleError } from "@/lib/error"; import { ROUTES } from "@/router/routes"; import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; -import { AUTH_REASON_PARAM, AUTH_REASON_PROTECTED_MEMO, AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; +import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; const SignUp = () => { @@ -29,7 +29,6 @@ const SignUp = () => { const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance(); const [searchParams] = useSearchParams(); const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); - const authReason = searchParams.get(AUTH_REASON_PARAM); const signInPath = searchParams.toString() ? `${ROUTES.AUTH}?${searchParams.toString()}` : ROUTES.AUTH; const handleUsernameInputChanged = (e: React.ChangeEvent) => { @@ -94,11 +93,6 @@ const SignUp = () => {

{instanceGeneralSetting.customProfile?.title || "Memos"}

- {authReason === AUTH_REASON_PROTECTED_MEMO && ( -
- {t("auth.protected-memo-notice")} -
- )} {!instanceGeneralSetting.disallowUserRegistration ? ( <>

{t("auth.create-your-account")}

diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 7bdffc6fe..7375e1e68 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -33,7 +33,6 @@ const NotFound = lazyWithReload(() => import("@/pages/NotFound")); const PermissionDenied = lazyWithReload(() => import("@/pages/PermissionDenied")); const Attachments = lazyWithReload(() => import("@/pages/Attachments")); const Setting = lazyWithReload(() => import("@/pages/Setting")); -const SharedMemo = lazyWithReload(() => import("@/pages/SharedMemo")); const SignIn = lazyWithReload(() => import("@/pages/SignIn")); const SignUp = lazyWithReload(() => import("@/pages/SignUp")); const UserProfile = lazyWithReload(() => import("@/pages/UserProfile")); @@ -76,13 +75,12 @@ const router = createBrowserRouter([ { path: Routes.INBOX, element: }, { path: Routes.SETTING, element: }, { path: "memos/:uid", element: }, + { path: "memos/shares/:token", element: }, { path: "403", element: }, { path: "404", element: }, { path: "*", element: }, ], }, - // Public share-link viewer — outside RootLayout to bypass auth-gating - { path: "memos/shares/:token", element: }, ], }, ]);