0.29.1原版
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useUser } from "@/hooks/useUserQueries";
|
||||
import { findTagMetadata } from "@/lib/tag";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { isSuperUser } from "@/utils/user";
|
||||
import MemoShareImageDialog from "../MemoActionMenu/MemoShareImageDialog";
|
||||
import MemoEditor from "../MemoEditor";
|
||||
import PreviewImageDialog from "../PreviewImageDialog";
|
||||
import { MemoBody, MemoCommentListView, MemoHeader } from "./components";
|
||||
import { MEMO_CARD_BASE_CLASSES } from "./constants";
|
||||
import { useImagePreview } from "./hooks";
|
||||
import { computeCommentAmount, MemoViewContext } from "./MemoViewContext";
|
||||
import type { MemoViewProps } from "./types";
|
||||
|
||||
const MemoView: React.FC<MemoViewProps> = (props: MemoViewProps) => {
|
||||
const { memo: memoData, className, parentPage: parentPageProp, compact, showCreator, showVisibility, showPinned } = props;
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [showEditor, setShowEditor] = useState(false);
|
||||
const [cardWidth, setCardWidth] = useState(0);
|
||||
|
||||
const currentUser = useCurrentUser();
|
||||
const { tagsSetting } = useInstance();
|
||||
const creator = useUser(memoData.creator).data;
|
||||
const isArchived = memoData.state === State.ARCHIVED;
|
||||
const readonly = memoData.creator !== currentUser?.name && !isSuperUser(currentUser);
|
||||
const parentPage = parentPageProp || "/";
|
||||
|
||||
// Blur content when any tag has blur_content enabled in the instance tag settings.
|
||||
const [showBlurredContent, setShowBlurredContent] = useState(false);
|
||||
const blurred = memoData.tags?.some((tag) => findTagMetadata(tag, tagsSetting)?.blurContent) ?? false;
|
||||
const toggleBlurVisibility = useCallback(() => setShowBlurredContent((prev) => !prev), []);
|
||||
|
||||
const { previewState, openPreview, setPreviewOpen } = useImagePreview();
|
||||
|
||||
const openEditor = useCallback(() => setShowEditor(true), []);
|
||||
const closeEditor = useCallback(() => setShowEditor(false), []);
|
||||
|
||||
const location = useLocation();
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/");
|
||||
const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateWidth = (nextWidth?: number) => {
|
||||
const width = Math.round(nextWidth ?? card.getBoundingClientRect().width);
|
||||
setCardWidth((prev) => (prev === width ? prev : width));
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
const handleResize = () => updateWidth();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
updateWidth(entries[0]?.contentRect.width);
|
||||
});
|
||||
|
||||
resizeObserver.observe(card);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
memo: memoData,
|
||||
creator,
|
||||
currentUser,
|
||||
parentPage,
|
||||
cardWidth,
|
||||
isArchived,
|
||||
readonly,
|
||||
showBlurredContent,
|
||||
blurred,
|
||||
openEditor,
|
||||
toggleBlurVisibility,
|
||||
openPreview,
|
||||
}),
|
||||
[
|
||||
memoData,
|
||||
creator,
|
||||
currentUser,
|
||||
parentPage,
|
||||
cardWidth,
|
||||
isArchived,
|
||||
readonly,
|
||||
showBlurredContent,
|
||||
blurred,
|
||||
openEditor,
|
||||
toggleBlurVisibility,
|
||||
openPreview,
|
||||
],
|
||||
);
|
||||
|
||||
if (showEditor) {
|
||||
return (
|
||||
<MemoEditor
|
||||
autoFocus
|
||||
className="mb-2"
|
||||
cacheKey={`inline-memo-editor-${memoData.name}`}
|
||||
memo={memoData}
|
||||
parentMemoName={memoData.parent || undefined}
|
||||
onConfirm={closeEditor}
|
||||
onCancel={closeEditor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const article = (
|
||||
<article
|
||||
className={cn(MEMO_CARD_BASE_CLASSES, showCommentPreview ? "mb-0 rounded-b-none" : "mb-2", className)}
|
||||
ref={cardRef}
|
||||
tabIndex={readonly ? -1 : 0}
|
||||
>
|
||||
<MemoHeader showCreator={showCreator} showVisibility={showVisibility} showPinned={showPinned} />
|
||||
|
||||
<MemoBody compact={compact} />
|
||||
|
||||
<PreviewImageDialog
|
||||
open={previewState.open}
|
||||
onOpenChange={setPreviewOpen}
|
||||
items={previewState.items}
|
||||
initialIndex={previewState.index}
|
||||
/>
|
||||
|
||||
{props.onShareImageDialogOpenChange && (
|
||||
<MemoShareImageDialog open={Boolean(props.shareImageDialogOpen)} onOpenChange={props.onShareImageDialogOpenChange} />
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
|
||||
return (
|
||||
<MemoViewContext.Provider value={contextValue}>
|
||||
{showCommentPreview ? (
|
||||
<div className="w-full mb-2">
|
||||
{article}
|
||||
<MemoCommentListView />
|
||||
</div>
|
||||
) : (
|
||||
article
|
||||
)}
|
||||
</MemoViewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(MemoView);
|
||||
@@ -0,0 +1,66 @@
|
||||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { createContext, useContext } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useView } from "@/contexts/ViewContext";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
import type { PreviewMediaItem } from "@/utils/media-item";
|
||||
import { RELATIVE_TIME_THRESHOLD_MS } from "./constants";
|
||||
|
||||
export interface MemoViewContextValue {
|
||||
memo: Memo;
|
||||
creator: User | undefined;
|
||||
currentUser: User | undefined;
|
||||
parentPage: string;
|
||||
cardWidth: number;
|
||||
isArchived: boolean;
|
||||
readonly: boolean;
|
||||
showBlurredContent: boolean;
|
||||
blurred: boolean;
|
||||
openEditor: () => void;
|
||||
toggleBlurVisibility: () => void;
|
||||
openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
|
||||
}
|
||||
|
||||
export const MemoViewContext = createContext<MemoViewContextValue | null>(null);
|
||||
|
||||
export const useMemoViewContext = (): MemoViewContextValue => {
|
||||
const context = useContext(MemoViewContext);
|
||||
if (!context) {
|
||||
throw new Error("useMemoViewContext must be used within MemoViewContext.Provider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const computeCommentAmount = (memo: Memo): number =>
|
||||
memo.relations.filter((r) => r.type === MemoRelation_Type.COMMENT && r.relatedMemo?.name === memo.name).length;
|
||||
|
||||
export const useMemoViewDerived = () => {
|
||||
const { memo, isArchived, readonly } = useMemoViewContext();
|
||||
const { timeBasis } = useView();
|
||||
const location = useLocation();
|
||||
|
||||
const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`) || location.pathname.startsWith("/memos/shares/");
|
||||
const commentAmount = computeCommentAmount(memo);
|
||||
|
||||
const createTime = memo.createTime ? timestampDate(memo.createTime) : undefined;
|
||||
const updateTime = memo.updateTime ? timestampDate(memo.updateTime) : undefined;
|
||||
const displayTime = timeBasis === "update_time" ? updateTime : createTime;
|
||||
const isDisplayingUpdatedTime =
|
||||
timeBasis === "update_time" && !!createTime && !!updateTime && updateTime.getTime() !== createTime.getTime();
|
||||
const relativeTimeFormat: "datetime" | "auto" =
|
||||
displayTime && Date.now() - displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto";
|
||||
|
||||
return {
|
||||
isArchived,
|
||||
readonly,
|
||||
isInMemoDetailPage,
|
||||
commentAmount,
|
||||
createTime,
|
||||
updateTime,
|
||||
displayTime,
|
||||
isDisplayingUpdatedTime,
|
||||
relativeTimeFormat,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMemo } from "react";
|
||||
import { AttachmentListView, LocationDisplayView, RelationListView } from "@/components/MemoMetadata";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import MemoContent from "../../MemoContent";
|
||||
import { MemoReactionListView } from "../../MemoReactionListView";
|
||||
import { useMemoHandlers } from "../hooks";
|
||||
import { useMemoViewContext } from "../MemoViewContext";
|
||||
import type { MemoBodyProps } from "../types";
|
||||
|
||||
const BlurOverlay: React.FC<{ onClick?: () => void }> = ({ onClick }) => {
|
||||
const t = useTranslate();
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 pt-4 flex items-center justify-center" onClick={onClick}>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-border bg-card px-2 py-1 text-xs text-muted-foreground transition-colors hover:border-accent hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{t("memo.click-to-show-sensitive-content")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getContentRevision = (content: string) => {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
hash ^= content.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `${content.length}-${hash >>> 0}`;
|
||||
};
|
||||
|
||||
const MemoBody: React.FC<MemoBodyProps> = ({ compact }) => {
|
||||
const { memo, parentPage, showBlurredContent, blurred, readonly, openEditor, openPreview, toggleBlurVisibility } = useMemoViewContext();
|
||||
|
||||
const { handleMemoContentClick, handleMemoContentDoubleClick } = useMemoHandlers({ readonly, openEditor, openPreview });
|
||||
|
||||
const referencedMemos = memo.relations.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
const contentRevision = useMemo(() => getContentRevision(memo.content), [memo.content]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex flex-col justify-start items-start gap-2",
|
||||
blurred && !showBlurredContent && "blur-lg transition-all duration-200",
|
||||
)}
|
||||
>
|
||||
<MemoContent
|
||||
key={`${memo.name}-${contentRevision}`}
|
||||
content={memo.content}
|
||||
onClick={handleMemoContentClick}
|
||||
onDoubleClick={handleMemoContentDoubleClick}
|
||||
compact={memo.pinned ? false : compact} // Always show full content when pinned
|
||||
/>
|
||||
<AttachmentListView attachments={memo.attachments} onImagePreview={openPreview} />
|
||||
<RelationListView relations={referencedMemos} currentMemoName={memo.name} parentPage={parentPage} />
|
||||
{memo.location && <LocationDisplayView location={memo.location} />}
|
||||
<MemoReactionListView memo={memo} reactions={memo.reactions} />
|
||||
</div>
|
||||
|
||||
{blurred && !showBlurredContent && <BlurOverlay onClick={toggleBlurVisibility} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoBody;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ArrowUpRightIcon } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { MemoPreview } from "@/components/MemoPreview";
|
||||
import { extractMemoIdFromName } from "@/helpers/resource-names";
|
||||
import { useMemoComments } from "@/hooks/useMemoQueries";
|
||||
import { useUsersByNames } from "@/hooks/useUserQueries";
|
||||
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
|
||||
|
||||
const MemoCommentListView: React.FC = () => {
|
||||
const { memo } = useMemoViewContext();
|
||||
const { isInMemoDetailPage, commentAmount } = useMemoViewDerived();
|
||||
|
||||
const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0, pageSize: 3 });
|
||||
const comments = data?.memos ?? [];
|
||||
const displayedComments = comments.slice(0, 3);
|
||||
const { data: commentCreators } = useUsersByNames(displayedComments.map((comment) => comment.creator));
|
||||
|
||||
if (isInMemoDetailPage || commentAmount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-t-0 border-border rounded-b-lg px-4 pt-2 pb-3 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-muted-foreground">Comments{commentAmount > 1 ? ` (${commentAmount})` : ""}</span>
|
||||
<Link
|
||||
to={`/${memo.name}#comments`}
|
||||
className="flex items-center gap-0.5 text-xs text-muted-foreground/80 hover:underline underline-offset-2 transition-colors"
|
||||
>
|
||||
View all
|
||||
<ArrowUpRightIcon className="w-3 h-3" />
|
||||
</Link>
|
||||
</div>
|
||||
{displayedComments.map((comment) => {
|
||||
const uid = extractMemoIdFromName(comment.name);
|
||||
const creator = commentCreators?.get(comment.creator);
|
||||
return (
|
||||
<Link
|
||||
key={comment.name}
|
||||
to={`/${memo.name}#${uid}`}
|
||||
viewTransition
|
||||
className="rounded-md bg-muted/40 px-2 py-1 transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<MemoPreview
|
||||
content={comment.snippet || comment.content}
|
||||
attachments={comment.attachments}
|
||||
creator={creator}
|
||||
showCreator
|
||||
truncate
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoCommentListView;
|
||||
@@ -0,0 +1,170 @@
|
||||
import { BookmarkIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
import i18n from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import type { User } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { convertVisibilityToString } from "@/utils/memo";
|
||||
import MemoActionMenu from "../../MemoActionMenu";
|
||||
import { ReactionSelector } from "../../MemoReactionListView";
|
||||
import UserAvatar from "../../UserAvatar";
|
||||
import VisibilityIcon from "../../VisibilityIcon";
|
||||
import { useMemoActions } from "../hooks";
|
||||
import { useMemoViewContext, useMemoViewDerived } from "../MemoViewContext";
|
||||
import type { MemoHeaderProps } from "../types";
|
||||
|
||||
const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, showPinned }) => {
|
||||
const t = useTranslate();
|
||||
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
|
||||
|
||||
const { memo, creator, currentUser, parentPage, isArchived, readonly, openEditor } = useMemoViewContext();
|
||||
const { createTime, updateTime, displayTime: memoDisplayTime, isDisplayingUpdatedTime, relativeTimeFormat } = useMemoViewDerived();
|
||||
|
||||
const navigateTo = useNavigateTo();
|
||||
const handleGotoMemoDetailPage = useCallback(() => {
|
||||
navigateTo(`/${memo.name}`, { state: { from: parentPage } });
|
||||
}, [memo.name, parentPage, navigateTo]);
|
||||
|
||||
const { unpinMemo } = useMemoActions(memo);
|
||||
|
||||
const timeValue = isArchived ? (
|
||||
memoDisplayTime?.toLocaleString(i18n.language)
|
||||
) : (
|
||||
<relative-time datetime={memoDisplayTime?.toISOString()} lang={i18n.language} format={relativeTimeFormat} no-title=""></relative-time>
|
||||
);
|
||||
const displayTime = isDisplayingUpdatedTime ? (
|
||||
<>
|
||||
{t("common.last-updated-at")} {timeValue}
|
||||
</>
|
||||
) : (
|
||||
timeValue
|
||||
);
|
||||
const timeTooltip = {
|
||||
createdAt: createTime ? `${t("common.created-at")}: ${createTime.toLocaleString(i18n.language)}` : undefined,
|
||||
updatedAt: updateTime ? `${t("common.last-updated-at")}: ${updateTime.toLocaleString(i18n.language)}` : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-row justify-between items-center gap-2">
|
||||
<div className="w-auto max-w-[calc(100%-8rem)] grow flex flex-row justify-start items-center">
|
||||
{showCreator && creator ? (
|
||||
<CreatorDisplay creator={creator} displayTime={displayTime} timeTooltip={timeTooltip} onGotoDetail={handleGotoMemoDetailPage} />
|
||||
) : (
|
||||
<TimeDisplay displayTime={displayTime} timeTooltip={timeTooltip} onGotoDetail={handleGotoMemoDetailPage} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
|
||||
{currentUser && !isArchived && (
|
||||
<ReactionSelector
|
||||
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "block!", "block sm:hidden sm:group-hover:block")}
|
||||
memo={memo}
|
||||
onOpenChange={setReactionSelectorOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showVisibility && memo.visibility !== Visibility.PRIVATE && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span className="flex justify-center items-center rounded-md hover:opacity-80">
|
||||
<VisibilityIcon visibility={memo.visibility} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t(`memo.visibility.${convertVisibilityToString(memo.visibility).toLowerCase()}` as Parameters<typeof t>[0])}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{showPinned && memo.pinned && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-pointer">
|
||||
<BookmarkIcon className="w-4 h-auto text-primary" onClick={unpinMemo} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.unpin")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<MemoActionMenu memo={memo} readonly={readonly} onEdit={openEditor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CreatorDisplayProps {
|
||||
creator: User;
|
||||
displayTime: React.ReactNode;
|
||||
timeTooltip: TimeTooltipContent;
|
||||
onGotoDetail: () => void;
|
||||
}
|
||||
|
||||
const CreatorDisplay: React.FC<CreatorDisplayProps> = ({ creator, displayTime, timeTooltip, onGotoDetail }) => (
|
||||
<div className="w-full flex flex-row justify-start items-center">
|
||||
<Link className="w-auto hover:opacity-80 rounded-md transition-colors" to={`/u/${encodeURIComponent(creator.username)}`} viewTransition>
|
||||
<UserAvatar className="mr-2 shrink-0" avatarUrl={creator.avatarUrl} />
|
||||
</Link>
|
||||
<div className="w-full flex flex-col justify-center items-start">
|
||||
<Link
|
||||
className="block leading-tight hover:opacity-80 rounded-md transition-colors truncate text-muted-foreground"
|
||||
to={`/u/${encodeURIComponent(creator.username)}`}
|
||||
viewTransition
|
||||
>
|
||||
{creator.displayName || creator.username}
|
||||
</Link>
|
||||
<TimeTooltip content={timeTooltip}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-auto -mt-0.5 text-xs leading-tight text-muted-foreground select-none cursor-pointer hover:opacity-80 transition-colors text-left"
|
||||
onClick={onGotoDetail}
|
||||
>
|
||||
{displayTime}
|
||||
</button>
|
||||
</TimeTooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface TimeTooltipContent {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const TimeTooltip = ({ children, content }: { children: React.ReactElement; content: TimeTooltipContent }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent align="start" className="flex flex-col items-start gap-0.5 whitespace-nowrap text-left">
|
||||
{content.createdAt && <span>{content.createdAt}</span>}
|
||||
{content.updatedAt && <span>{content.updatedAt}</span>}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
interface TimeDisplayProps {
|
||||
displayTime: React.ReactNode;
|
||||
timeTooltip: TimeTooltipContent;
|
||||
onGotoDetail: () => void;
|
||||
}
|
||||
|
||||
const TimeDisplay: React.FC<TimeDisplayProps> = ({ displayTime, timeTooltip, onGotoDetail }) => (
|
||||
<TimeTooltip content={timeTooltip}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-auto text-sm leading-tight text-muted-foreground select-none cursor-pointer hover:text-foreground transition-colors text-left"
|
||||
onClick={onGotoDetail}
|
||||
>
|
||||
{displayTime}
|
||||
</button>
|
||||
</TimeTooltip>
|
||||
);
|
||||
|
||||
export default MemoHeader;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { extractMemoIdFromName } from "@/helpers/resource-names";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MemoSnippetLinkProps {
|
||||
name: string;
|
||||
snippet: string;
|
||||
to: string;
|
||||
state?: object;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MemoSnippetLink = ({ name, snippet, to, state, className }: MemoSnippetLinkProps) => {
|
||||
const memoId = extractMemoIdFromName(name);
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={cn(
|
||||
"flex items-center gap-1 px-1 py-1 rounded text-xs text-muted-foreground hover:text-foreground hover:bg-accent/20 transition-colors group",
|
||||
className,
|
||||
)}
|
||||
to={to}
|
||||
viewTransition
|
||||
state={state}
|
||||
>
|
||||
<span className="text-[8px] font-mono px-1 py-0.5 rounded border border-border bg-muted/40 group-hover:bg-accent/30 transition-colors shrink-0">
|
||||
{memoId.slice(0, 6)}
|
||||
</span>
|
||||
<span className="truncate">{snippet || <span className="italic opacity-60">No content</span>}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemoSnippetLink;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as MemoBody } from "./MemoBody";
|
||||
export { default as MemoCommentListView } from "./MemoCommentListView";
|
||||
export { default as MemoHeader } from "./MemoHeader";
|
||||
@@ -0,0 +1,4 @@
|
||||
export const MEMO_CARD_BASE_CLASSES =
|
||||
"relative group flex flex-col justify-start items-start bg-card w-full px-4 py-3 mb-2 gap-2 text-card-foreground rounded-lg border border-border transition-colors";
|
||||
|
||||
export const RELATIVE_TIME_THRESHOLD_MS = 1000 * 60 * 60 * 24;
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useImagePreview } from "./useImagePreview";
|
||||
export { useMemoActions } from "./useMemoActions";
|
||||
export { useMemoHandlers } from "./useMemoHandlers";
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { PreviewMediaItem } from "@/utils/media-item";
|
||||
|
||||
export interface ImagePreviewState {
|
||||
open: boolean;
|
||||
items: PreviewMediaItem[];
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface UseImagePreviewReturn {
|
||||
previewState: ImagePreviewState;
|
||||
openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
|
||||
setPreviewOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useImagePreview = (): UseImagePreviewReturn => {
|
||||
const [previewState, setPreviewState] = useState<ImagePreviewState>({ open: false, items: [], index: 0 });
|
||||
|
||||
const openPreview = useCallback((items: string | string[] | PreviewMediaItem[], index = 0) => {
|
||||
const normalizedItems = normalizePreviewItems(items);
|
||||
setPreviewState({ open: true, items: normalizedItems, index });
|
||||
}, []);
|
||||
|
||||
const setPreviewOpen = useCallback((open: boolean) => {
|
||||
setPreviewState((prev) => ({ ...prev, open }));
|
||||
}, []);
|
||||
|
||||
return { previewState, openPreview, setPreviewOpen };
|
||||
};
|
||||
|
||||
function normalizePreviewItems(items: string | string[] | PreviewMediaItem[]): PreviewMediaItem[] {
|
||||
if (typeof items === "string") {
|
||||
return [
|
||||
{
|
||||
id: items,
|
||||
kind: "image",
|
||||
sourceUrl: items,
|
||||
posterUrl: items,
|
||||
filename: "Image",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (Array.isArray(items) && (items.length === 0 || typeof items[0] === "string")) {
|
||||
return (items as string[]).map((url) => ({
|
||||
id: url,
|
||||
kind: "image",
|
||||
sourceUrl: url,
|
||||
posterUrl: url,
|
||||
filename: "Image",
|
||||
}));
|
||||
}
|
||||
|
||||
return items as PreviewMediaItem[];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useUpdateMemo } from "@/hooks/useMemoQueries";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export const useMemoActions = (memo: Memo) => {
|
||||
const { mutateAsync: updateMemo } = useUpdateMemo();
|
||||
|
||||
const unpinMemo = async () => {
|
||||
if (!memo.pinned) return;
|
||||
await updateMemo({ update: { name: memo.name, pinned: false }, updateMask: ["pinned"] });
|
||||
};
|
||||
|
||||
return { unpinMemo };
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from "react";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import type { PreviewMediaItem } from "@/utils/media-item";
|
||||
|
||||
interface UseMemoHandlersOptions {
|
||||
readonly: boolean;
|
||||
openEditor: () => void;
|
||||
openPreview: (items: string | string[] | PreviewMediaItem[], index?: number) => void;
|
||||
}
|
||||
|
||||
export const useMemoHandlers = (options: UseMemoHandlersOptions) => {
|
||||
const { readonly, openEditor, openPreview } = options;
|
||||
const { memoRelatedSetting } = useInstance();
|
||||
|
||||
const handleMemoContentClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const targetEl = e.target as HTMLElement;
|
||||
if (targetEl.tagName === "IMG") {
|
||||
const linkElement = targetEl.closest("a");
|
||||
if (linkElement) return; // If image is inside a link, don't show preview
|
||||
const imgUrl = targetEl.getAttribute("src");
|
||||
if (imgUrl) openPreview(imgUrl);
|
||||
}
|
||||
},
|
||||
[openPreview],
|
||||
);
|
||||
|
||||
const handleMemoContentDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (readonly) return;
|
||||
if (memoRelatedSetting.enableDoubleClickEdit) {
|
||||
e.preventDefault();
|
||||
openEditor();
|
||||
}
|
||||
},
|
||||
[readonly, openEditor, memoRelatedSetting.enableDoubleClickEdit],
|
||||
);
|
||||
|
||||
return { handleMemoContentClick, handleMemoContentDoubleClick };
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default, default as MemoView } from "./MemoView";
|
||||
export type { MemoViewProps } from "./types";
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
||||
export interface MemoViewProps {
|
||||
memo: Memo;
|
||||
compact?: boolean;
|
||||
showCreator?: boolean;
|
||||
showVisibility?: boolean;
|
||||
showPinned?: boolean;
|
||||
className?: string;
|
||||
parentPage?: string;
|
||||
shareImageDialogOpen?: boolean;
|
||||
onShareImageDialogOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface MemoHeaderProps {
|
||||
showCreator?: boolean;
|
||||
showVisibility?: boolean;
|
||||
showPinned?: boolean;
|
||||
}
|
||||
|
||||
export interface MemoBodyProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user