mirror of https://github.com/usememos/memos.git
refactor(web): consolidate SharedMemo into MemoDetail (#5773)
Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com>
This commit is contained in:
parent
6b30579903
commit
bb7f4978e5
|
|
@ -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