mirror of https://github.com/usememos/memos.git
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
This commit is contained in:
parent
6b30579903
commit
1e5e49c1a2
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)}` };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue