mirror of https://github.com/usememos/memos
feat: render link metadata cards
parent
9c5c604944
commit
0bc56694b0
@ -0,0 +1,70 @@
|
|||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useLinkMetadata } from "@/hooks/useMemoQueries";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface LinkMetadataCardProps {
|
||||||
|
url: string;
|
||||||
|
fallback: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHostname(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkMetadataCard = ({ url, fallback }: LinkMetadataCardProps) => {
|
||||||
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
|
const { data: metadata, isSuccess } = useLinkMetadata(url);
|
||||||
|
|
||||||
|
const title = metadata?.title.trim() ?? "";
|
||||||
|
const description = metadata?.description.trim() ?? "";
|
||||||
|
const image = metadata?.image.trim() ?? "";
|
||||||
|
const hostname = getHostname(metadata?.url || url);
|
||||||
|
const hasUsefulMetadata = title !== "" || description !== "";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageFailed(false);
|
||||||
|
}, [url, image]);
|
||||||
|
|
||||||
|
if (!isSuccess || !hasUsefulMetadata) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn(
|
||||||
|
"group my-0 mb-2 flex w-full max-w-full overflow-hidden rounded-md border border-border bg-muted/20 text-foreground no-underline transition-colors",
|
||||||
|
"hover:border-primary/35 hover:bg-accent/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col gap-0.5 px-2.5 py-2 sm:gap-1 sm:px-3 sm:py-2.5">
|
||||||
|
{hostname && <span className="truncate text-[11px] leading-4 text-muted-foreground sm:text-xs">{hostname}</span>}
|
||||||
|
{title && <span className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{title}</span>}
|
||||||
|
{description && <span className="line-clamp-1 text-xs leading-4 text-muted-foreground sm:line-clamp-2">{description}</span>}
|
||||||
|
</span>
|
||||||
|
{image && !imageFailed && (
|
||||||
|
<span className="flex w-24 shrink-0 items-center border-l border-border/70 bg-muted/40 sm:w-40">
|
||||||
|
<span className="aspect-[1.91/1] w-full overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.01]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={() => setImageFailed(true)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LinkMetadataCard;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { createContext, useContext, useMemo } from "react";
|
||||||
|
|
||||||
|
interface MarkdownRenderContextValue {
|
||||||
|
blockDepth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rootMarkdownRenderContext: MarkdownRenderContextValue = {
|
||||||
|
blockDepth: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MarkdownRenderContext = createContext<MarkdownRenderContextValue>(rootMarkdownRenderContext);
|
||||||
|
|
||||||
|
export const useMarkdownRenderContext = () => {
|
||||||
|
return useContext(MarkdownRenderContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NestedMarkdownRenderContext = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { blockDepth } = useMarkdownRenderContext();
|
||||||
|
const value = useMemo<MarkdownRenderContextValue>(() => ({ blockDepth: blockDepth + 1 }), [blockDepth]);
|
||||||
|
|
||||||
|
return <MarkdownRenderContext.Provider value={value}>{children}</MarkdownRenderContext.Provider>;
|
||||||
|
};
|
||||||
@ -1,17 +1,47 @@
|
|||||||
|
import type { Element } from "hast";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import LinkMetadataCard from "../LinkMetadataCard";
|
||||||
|
import { useMarkdownRenderContext } from "../MarkdownRenderContext";
|
||||||
import type { ReactMarkdownProps } from "./types";
|
import type { ReactMarkdownProps } from "./types";
|
||||||
|
|
||||||
interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, ReactMarkdownProps {
|
interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, ReactMarkdownProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function getSingleLinkHref(node?: Element): string | undefined {
|
||||||
* Paragraph component with compact spacing
|
if (!node || node.tagName !== "p") {
|
||||||
*/
|
return undefined;
|
||||||
export const Paragraph = ({ children, className, node: _node, ...props }: ParagraphProps) => {
|
}
|
||||||
return (
|
|
||||||
|
const meaningfulChildren = node.children.filter((child) => {
|
||||||
|
return !(child.type === "text" && child.value.trim() === "");
|
||||||
|
});
|
||||||
|
|
||||||
|
if (meaningfulChildren.length !== 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlyChild = meaningfulChildren[0];
|
||||||
|
if (onlyChild.type !== "element" || onlyChild.tagName !== "a") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const href = onlyChild.properties?.href;
|
||||||
|
return typeof href === "string" ? href : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => {
|
||||||
|
const { blockDepth } = useMarkdownRenderContext();
|
||||||
|
const href = blockDepth === 0 ? getSingleLinkHref(node) : undefined;
|
||||||
|
const paragraph = (
|
||||||
<p className={cn("my-0 mb-2 leading-6", className)} {...props}>
|
<p className={cn("my-0 mb-2 leading-6", className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return <LinkMetadataCard url={href} fallback={paragraph} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return paragraph;
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue