refactor(web): consolidate SharedMemo into MemoDetail (#5773)

Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
This commit is contained in:
memoclaw 2026-03-24 08:40:49 +08:00 committed by GitHub
parent 6b30579903
commit bb7f4978e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 55 additions and 130 deletions

View File

@ -38,7 +38,7 @@ const MemoView: React.FC<MemoViewProps> = (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(

View File

@ -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;

View File

@ -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;

View File

@ -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)}` };
});
}

View File

@ -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 <Navigate to="/404" replace />;
}
}
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 (
<section className="@container w-full max-w-5xl min-h-full flex flex-col justify-start items-center sm:pt-3 md:pt-6 pb-8">
{!md && (
<MobileHeader>
<MemoDetailSidebarDrawer memo={memo} />
<MemoDetailSidebarDrawer memo={displayMemo} />
</MobileHeader>
)}
<div className={cn("w-full flex flex-row justify-start items-start px-4 sm:px-6 gap-4")}>
@ -69,19 +96,19 @@ const MemoDetail = () => {
</div>
)}
<MemoView
key={`${memo.name}-${memo.displayTime}`}
memo={memo}
key={`${displayMemo.name}-${displayMemo.displayTime}`}
memo={displayMemo}
compact={false}
parentPage={locationState?.from}
showCreator
showVisibility
showPinned
/>
<MemoCommentSection memo={memo} comments={comments} parentPage={locationState?.from} />
<MemoCommentSection memo={displayMemo} comments={comments} parentPage={locationState?.from} />
</div>
{md && (
<div className="sticky top-0 left-0 shrink-0 -mt-6 w-56 h-full">
<MemoDetailSidebar className="py-6" memo={memo} />
<MemoDetailSidebar className="py-6" memo={displayMemo} />
</div>
)}
</div>

View File

@ -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 (
<div className="flex h-screen items-center justify-center">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (isNotFound || (!isLoading && !memo)) {
return (
<div className="flex h-screen flex-col items-center justify-center gap-3 text-center">
<AlertCircleIcon className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t("memo.share.invalid-link")}</p>
</div>
);
}
if (error || !memo) return null;
const displayDate = (memo.displayTime as Timestamp | undefined)
? timestampDate(memo.displayTime as Timestamp)?.toLocaleString(i18n.language)
: null;
return (
<div className="mx-auto w-full min-w-80 max-w-2xl px-4 py-8">
{/* Creator + date above the card */}
<div className="mb-3 flex flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<UserAvatar className="shrink-0" avatarUrl={creator?.avatarUrl} />
<span className="text-sm text-muted-foreground">{creator?.displayName || creator?.username || memo.creator}</span>
</div>
{displayDate && <span className="text-xs text-muted-foreground">{displayDate}</span>}
</div>
<div className="relative flex flex-col items-start gap-2 rounded-lg border border-border bg-card px-4 py-3 text-card-foreground">
<MemoContent content={memo.content} />
{memo.attachments.length > 0 && (
<AttachmentListView attachments={withShareAttachmentLinks(memo.attachments, token)} onImagePreview={openPreview} />
)}
</div>
<PreviewImageDialog
open={previewState.open}
onOpenChange={setPreviewOpen}
imgUrls={previewState.urls}
initialIndex={previewState.index}
/>
</div>
);
};
export default SharedMemo;

View File

@ -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 = () => {
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
{authReason === AUTH_REASON_PROTECTED_MEMO && (
<div className="w-full mb-4 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t("auth.protected-memo-notice")}
</div>
)}
{!instanceGeneralSetting.disallowPasswordAuth ? (
<PasswordSignInForm redirectPath={redirectTarget} />
) : (

View File

@ -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<HTMLInputElement>) => {
@ -94,11 +93,6 @@ const SignUp = () => {
<img className="h-14 w-auto rounded-full shadow" src={instanceGeneralSetting.customProfile?.logoUrl || "/logo.webp"} alt="" />
<p className="ml-2 text-5xl text-foreground opacity-80">{instanceGeneralSetting.customProfile?.title || "Memos"}</p>
</div>
{authReason === AUTH_REASON_PROTECTED_MEMO && (
<div className="w-full mb-4 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t("auth.protected-memo-notice")}
</div>
)}
{!instanceGeneralSetting.disallowUserRegistration ? (
<>
<p className="w-full text-2xl mt-2 text-muted-foreground">{t("auth.create-your-account")}</p>

View File

@ -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: <Inboxes /> },
{ path: Routes.SETTING, element: <Setting /> },
{ path: "memos/:uid", element: <MemoDetail /> },
{ path: "memos/shares/:token", element: <MemoDetail /> },
{ path: "403", element: <PermissionDenied /> },
{ path: "404", element: <NotFound /> },
{ path: "*", element: <NotFound /> },
],
},
// Public share-link viewer — outside RootLayout to bypass auth-gating
{ path: "memos/shares/:token", element: <SharedMemo /> },
],
},
]);