mirror of https://github.com/usememos/memos.git
Merge d0c9e09098 into 6926764b91
This commit is contained in:
commit
708d246806
|
|
@ -0,0 +1,46 @@
|
|||
package httpgetter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
)
|
||||
|
||||
// GetFirstImageURL returns the first <img src> found on the page, or empty string.
|
||||
func GetFirstImageURL(urlStr string) (string, error) {
|
||||
if err := validateURL(urlStr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := httpClient.Get(urlStr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", errors.Errorf("failed to fetch page: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
tokenizer := html.NewTokenizer(resp.Body)
|
||||
for {
|
||||
tt := tokenizer.Next()
|
||||
if tt == html.ErrorToken {
|
||||
break
|
||||
}
|
||||
if tt == html.StartTagToken || tt == html.SelfClosingTagToken {
|
||||
token := tokenizer.Token()
|
||||
if token.DataAtom == atom.Img {
|
||||
for _, attr := range token.Attr {
|
||||
if strings.EqualFold(attr.Key, "src") && attr.Val != "" {
|
||||
return attr.Val, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
|
@ -43,6 +43,10 @@ func GetHTMLMeta(urlStr string) (*HTMLMeta, error) {
|
|||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= 400 {
|
||||
return nil, errors.Errorf("failed to fetch page: status %d", response.StatusCode)
|
||||
}
|
||||
|
||||
mediatype, err := getMediatype(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
syntax = "proto3";
|
||||
|
||||
package memos.api.v1;
|
||||
|
||||
import "google/api/annotations.proto";
|
||||
import "google/api/client.proto";
|
||||
import "google/api/field_behavior.proto";
|
||||
import "google/api/resource.proto";
|
||||
|
||||
option go_package = "gen/api/v1";
|
||||
|
||||
service LinkService {
|
||||
// GetLinkPreview fetches preview metadata for a URL (title, description, image).
|
||||
rpc GetLinkPreview(GetLinkPreviewRequest) returns (GetLinkPreviewResponse) {
|
||||
option (google.api.http) = {
|
||||
get: "/api/v1/link:preview"
|
||||
};
|
||||
option (google.api.method_signature) = "url";
|
||||
}
|
||||
}
|
||||
|
||||
message LinkPreview {
|
||||
option (google.api.resource) = {
|
||||
type: "memos.api.v1/LinkPreview"
|
||||
pattern: "linkPreviews/{link_preview}"
|
||||
name_field: "name"
|
||||
singular: "linkPreview"
|
||||
plural: "linkPreviews"
|
||||
};
|
||||
|
||||
// Resource name of the preview (server generated).
|
||||
// Format: linkPreviews/{link_preview}
|
||||
string name = 1 [
|
||||
(google.api.field_behavior) = OUTPUT_ONLY,
|
||||
(google.api.field_behavior) = IDENTIFIER
|
||||
];
|
||||
|
||||
// The original URL that was fetched.
|
||||
string url = 2 [(google.api.field_behavior) = REQUIRED];
|
||||
|
||||
// Extracted title of the page.
|
||||
string title = 3;
|
||||
|
||||
// Extracted description of the page.
|
||||
string description = 4;
|
||||
|
||||
// Resolved image URL for preview.
|
||||
string image_url = 5;
|
||||
|
||||
// Human readable site/host name.
|
||||
string site_name = 6;
|
||||
}
|
||||
|
||||
message GetLinkPreviewRequest {
|
||||
// URL to fetch metadata from.
|
||||
string url = 1 [(google.api.field_behavior) = REQUIRED];
|
||||
}
|
||||
|
||||
message GetLinkPreviewResponse {
|
||||
LinkPreview preview = 1 [(google.api.field_behavior) = REQUIRED];
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"github.com/usememos/memos/plugin/httpgetter"
|
||||
)
|
||||
|
||||
// RegisterLinkRoutes registers lightweight HTTP routes for link previews.
|
||||
// We keep this as a REST handler (not gRPC) to avoid schema churn
|
||||
// and to reuse existing safety checks in the httpgetter plugin.
|
||||
func (s *APIV1Service) RegisterLinkRoutes(g *echo.Group) {
|
||||
g.GET("/api/v1/link:preview", s.handleGetLinkPreview)
|
||||
}
|
||||
|
||||
type linkPreviewResponse struct {
|
||||
Preview linkPreview `json:"preview"`
|
||||
}
|
||||
|
||||
type linkPreview struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
SiteName string `json:"siteName"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
func (s *APIV1Service) handleGetLinkPreview(c echo.Context) error {
|
||||
_ = s
|
||||
rawURL := c.QueryParam("url")
|
||||
if rawURL == "" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "url is required")
|
||||
}
|
||||
|
||||
meta, err := httpgetter.GetHTMLMeta(rawURL)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||
}
|
||||
|
||||
parsedURL, _ := url.Parse(rawURL)
|
||||
siteName := ""
|
||||
if parsedURL != nil {
|
||||
siteName = parsedURL.Hostname()
|
||||
}
|
||||
|
||||
imageURL := meta.Image
|
||||
if parsedURL != nil && imageURL != "" {
|
||||
if u, err := url.Parse(imageURL); err == nil {
|
||||
if !u.IsAbs() {
|
||||
// handle protocol-relative
|
||||
if u.Host != "" {
|
||||
u.Scheme = parsedURL.Scheme
|
||||
imageURL = u.String()
|
||||
} else {
|
||||
// relative path -> join with base
|
||||
u.Scheme = parsedURL.Scheme
|
||||
u.Host = parsedURL.Host
|
||||
if !path.IsAbs(u.Path) {
|
||||
u.Path = path.Join(parsedURL.Path, "..", u.Path)
|
||||
}
|
||||
imageURL = u.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If meta image missing, try first <img> on page.
|
||||
if imageURL == "" {
|
||||
if firstImg, err := httpgetter.GetFirstImageURL(rawURL); err == nil && firstImg != "" {
|
||||
if parsedURL != nil {
|
||||
imageURL = toAbsoluteFromBase(parsedURL, firstImg)
|
||||
} else {
|
||||
imageURL = firstImg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp := linkPreviewResponse{
|
||||
Preview: linkPreview{
|
||||
Title: meta.Title,
|
||||
Description: meta.Description,
|
||||
ImageURL: imageURL,
|
||||
SiteName: siteName,
|
||||
URL: rawURL,
|
||||
},
|
||||
}
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func toAbsoluteFromBase(base *url.URL, raw string) string {
|
||||
if raw == "" || base == nil {
|
||||
return raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
if u.IsAbs() {
|
||||
return u.String()
|
||||
}
|
||||
// Protocol-relative //host/path
|
||||
if u.Host != "" && u.Scheme == "" {
|
||||
u.Scheme = base.Scheme
|
||||
return u.String()
|
||||
}
|
||||
// Pure relative path
|
||||
u.Scheme = base.Scheme
|
||||
u.Host = base.Host
|
||||
if !path.IsAbs(u.Path) {
|
||||
u.Path = path.Join(path.Dir(base.Path), u.Path)
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
|
@ -76,6 +76,8 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
|
|||
|
||||
// Create and register RSS routes (needs markdown service from apiV1Service).
|
||||
rss.NewRSSService(s.Profile, s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup)
|
||||
// Link preview helper route (REST).
|
||||
apiV1Service.RegisterLinkRoutes(rootGroup)
|
||||
// Register gRPC gateway as api v1.
|
||||
if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to register gRPC gateway")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { type LinkPreview, LinkPreviewCard } from "@/components/memo-metadata";
|
||||
|
||||
interface LinkPreviewBlockProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
node?: any;
|
||||
}
|
||||
|
||||
const LinkPreviewBlock = ({ node, ...rest }: LinkPreviewBlockProps) => {
|
||||
const props = node?.properties || {};
|
||||
const preview: LinkPreview = {
|
||||
id: props["data-id"] || props["dataId"] || cryptoId(),
|
||||
url: props["data-url"] || props.dataUrl || "",
|
||||
title: props["data-title"] || props.dataTitle || "Link preview",
|
||||
description: props["data-description"] || props.dataDescription || "",
|
||||
imageUrl: props["data-image"] || props.dataImage || "",
|
||||
siteName: props["data-site"] || props.dataSite || props["data-site-name"],
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...rest}>
|
||||
<LinkPreviewCard preview={preview} mode="view" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const isLinkPreviewNode = (node: any): boolean => {
|
||||
return node?.properties?.["data-memo-link-preview"] === "true" || node?.properties?.dataMemoLinkPreview === "true";
|
||||
};
|
||||
|
||||
function cryptoId(): string {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`;
|
||||
}
|
||||
|
||||
export default LinkPreviewBlock;
|
||||
|
|
@ -20,7 +20,7 @@ export const SANITIZE_SCHEMA = {
|
|||
...defaultSchema,
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
div: [...(defaultSchema.attributes?.div || []), "className"],
|
||||
div: [...(defaultSchema.attributes?.div || []), "className", ["data*"]],
|
||||
span: [...(defaultSchema.attributes?.span || []), "className", "style", ["aria*"], ["data*"]],
|
||||
// MathML attributes for KaTeX rendering
|
||||
annotation: ["encoding"],
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { CodeBlock } from "./CodeBlock";
|
|||
import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
|
||||
import { SANITIZE_SCHEMA } from "./constants";
|
||||
import { useCompactLabel, useCompactMode } from "./hooks";
|
||||
import LinkPreviewBlock, { isLinkPreviewNode } from "./LinkPreviewBlock";
|
||||
import { MemoContentContext } from "./MemoContentContext";
|
||||
import { Tag } from "./Tag";
|
||||
import { TaskListItem } from "./TaskListItem";
|
||||
|
|
@ -65,6 +66,7 @@ const MemoContent = observer((props: MemoContentProps) => {
|
|||
components={{
|
||||
// Conditionally render custom components based on AST node type
|
||||
input: createConditionalComponent(TaskListItem, "input", isTaskListItemNode),
|
||||
div: createConditionalComponent(LinkPreviewBlock, "div", isLinkPreviewNode),
|
||||
span: createConditionalComponent(Tag, "span", isTagNode),
|
||||
pre: CodeBlock,
|
||||
a: ({ href, children, ...props }) => (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { LatLng } from "leaflet";
|
||||
import { uniqBy } from "lodash-es";
|
||||
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon } from "lucide-react";
|
||||
import { FileIcon, LinkIcon, LoaderIcon, MapPinIcon, Maximize2Icon, MoreHorizontalIcon, PlusIcon, SparklesIcon } from "lucide-react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useContext, useState } from "react";
|
||||
import type { LocalFile } from "@/components/memo-metadata";
|
||||
import type { LinkPreview, LocalFile } from "@/components/memo-metadata";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { LinkMemoDialog, LocationDialog } from "../components";
|
||||
import { LinkMemoDialog, LinkPreviewDialog, LocationDialog } from "../components";
|
||||
import { GEOCODING } from "../constants";
|
||||
import { useFileUpload, useLinkMemo, useLocation } from "../hooks";
|
||||
import { useAbortController } from "../hooks/useAbortController";
|
||||
|
|
@ -26,6 +26,7 @@ interface Props {
|
|||
isUploading?: boolean;
|
||||
location?: Location;
|
||||
onLocationChange: (location?: Location) => void;
|
||||
onLinkPreviewAdd: (preview: LinkPreview) => void;
|
||||
onToggleFocusMode?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ const InsertMenu = observer((props: Props) => {
|
|||
const context = useContext(MemoEditorContext);
|
||||
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [linkPreviewDialogOpen, setLinkPreviewDialogOpen] = useState(false);
|
||||
const [locationDialogOpen, setLocationDialogOpen] = useState(false);
|
||||
|
||||
// Abort controller for canceling geocoding requests
|
||||
|
|
@ -153,6 +155,10 @@ const InsertMenu = observer((props: Props) => {
|
|||
<LinkIcon className="w-4 h-4" />
|
||||
{t("tooltip.link-memo")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLinkPreviewDialogOpen(true)}>
|
||||
<SparklesIcon className="w-4 h-4" />
|
||||
{t("editor.link-preview")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLocationClick}>
|
||||
<MapPinIcon className="w-4 h-4" />
|
||||
{t("tooltip.select-location")}
|
||||
|
|
@ -195,6 +201,15 @@ const InsertMenu = observer((props: Props) => {
|
|||
onSelectMemo={linkMemo.addMemoRelation}
|
||||
/>
|
||||
|
||||
<LinkPreviewDialog
|
||||
open={linkPreviewDialogOpen}
|
||||
onOpenChange={setLinkPreviewDialogOpen}
|
||||
onAddPreview={(preview) => {
|
||||
props.onLinkPreviewAdd(preview);
|
||||
setLinkPreviewDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<LocationDialog
|
||||
open={locationDialogOpen}
|
||||
onOpenChange={setLocationDialogOpen}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
import { LoaderIcon, SparklesIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { LinkPreview } from "@/components/memo-metadata";
|
||||
import LinkPreviewCard from "@/components/memo-metadata/LinkPreviewCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
|
||||
interface LinkPreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAddPreview: (preview: LinkPreview) => void;
|
||||
}
|
||||
|
||||
const LinkPreviewDialog = ({ open, onOpenChange, onAddPreview }: LinkPreviewDialogProps) => {
|
||||
const t = useTranslate();
|
||||
const [inputUrl, setInputUrl] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
const [preview, setPreview] = useState<LinkPreview | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setStatus("idle");
|
||||
setPreview(null);
|
||||
setError("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
try {
|
||||
return new URL(inputUrl).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return "example.com";
|
||||
}
|
||||
}, [inputUrl]);
|
||||
|
||||
const handlePreview = () => {
|
||||
if (!inputUrl.trim()) {
|
||||
setError(t("editor.link-preview-invalid"));
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(inputUrl);
|
||||
} catch (_err) {
|
||||
setError(t("editor.link-preview-invalid"));
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("loading");
|
||||
setError("");
|
||||
setPreview(null);
|
||||
|
||||
fetchPreview(inputUrl, { timeoutMs: 8000 })
|
||||
.then((res) => {
|
||||
setPreview(res);
|
||||
setStatus("ready");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const isAbort = err instanceof DOMException && err.name === "AbortError";
|
||||
setError(isAbort ? t("editor.link-preview-timeout") : t("editor.link-preview-fetch-error"));
|
||||
setStatus("error");
|
||||
setPreview(null);
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddPreview = () => {
|
||||
if (!preview) return;
|
||||
onAddPreview(preview);
|
||||
onOpenChange(false);
|
||||
setInputUrl("");
|
||||
setPreview(null);
|
||||
setStatus("idle");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("editor.scrape-link")}</DialogTitle>
|
||||
<DialogDescription>{t("editor.scrape-link-description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="link-preview-url" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
URL
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="link-preview-url"
|
||||
placeholder="https://example.com/article"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="secondary" onClick={handlePreview} disabled={status === "loading"}>
|
||||
{status === "loading" ? <LoaderIcon className="h-4 w-4 animate-spin" /> : t("editor.scrape-link")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
<span>{t("editor.scrape-link-description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/40 p-3">
|
||||
{status === "loading" && <Skeleton placeholder={placeholder} />}
|
||||
{status !== "loading" && preview && <LinkPreviewCard preview={preview} mode="edit" />}
|
||||
{status === "idle" && !preview && (
|
||||
<div className="flex flex-col items-start gap-1 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{t("common.preview")}</span>
|
||||
<span>{t("editor.link-preview-empty")}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="grid grid-cols-1 gap-3 rounded-md border bg-card/30 p-3">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="link-preview-title" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("common.title")}
|
||||
</Label>
|
||||
<Input
|
||||
id="link-preview-title"
|
||||
value={preview.title}
|
||||
onChange={(e) => setPreview({ ...preview, title: e.target.value })}
|
||||
placeholder="Edit title"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="link-preview-description" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{t("common.description")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="link-preview-description"
|
||||
value={preview.description}
|
||||
onChange={(e) => setPreview({ ...preview, description: e.target.value })}
|
||||
placeholder="Edit description"
|
||||
className="min-h-20"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="link-preview-image" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Cover image URL
|
||||
</Label>
|
||||
<Input
|
||||
id="link-preview-image"
|
||||
value={preview.imageUrl}
|
||||
onChange={(e) => setPreview({ ...preview, imageUrl: e.target.value })}
|
||||
placeholder="https://example.com/cover.png"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor="link-preview-site" className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Site name
|
||||
</Label>
|
||||
<Input
|
||||
id="link-preview-site"
|
||||
value={preview.siteName ?? ""}
|
||||
onChange={(e) => setPreview({ ...preview, siteName: e.target.value })}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button onClick={handleAddPreview} disabled={!preview || status === "loading"}>
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const Skeleton = ({ placeholder }: { placeholder: string }) => {
|
||||
return (
|
||||
<div className="flex w-full animate-pulse flex-col gap-2">
|
||||
<div className="h-24 w-full rounded-md bg-gradient-to-r from-muted to-muted-foreground/20" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-2/3 rounded bg-muted-foreground/30" />
|
||||
<div className="h-3 w-3/5 rounded bg-muted-foreground/20" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted-foreground/10" />
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<SparklesIcon className="h-3 w-3" />
|
||||
<span>{placeholder}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function _getHostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPreview(url: string, options?: { timeoutMs?: number }): Promise<LinkPreview> {
|
||||
const controller = new AbortController();
|
||||
const timeout = options?.timeoutMs ? window.setTimeout(() => controller.abort(), options.timeoutMs) : undefined;
|
||||
const response = await fetch(`/api/v1/link:preview?url=${encodeURIComponent(url)}`, {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
}).finally(() => {
|
||||
if (timeout) window.clearTimeout(timeout);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("failed to fetch preview");
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
preview: { title: string; description: string; imageUrl: string; siteName: string; url: string };
|
||||
};
|
||||
return {
|
||||
id: cryptoId(),
|
||||
url: data.preview.url,
|
||||
title: data.preview.title,
|
||||
description: data.preview.description,
|
||||
imageUrl: data.preview.imageUrl,
|
||||
siteName: data.preview.siteName,
|
||||
};
|
||||
}
|
||||
|
||||
function cryptoId(): string {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`;
|
||||
}
|
||||
|
||||
export default LinkPreviewDialog;
|
||||
|
|
@ -2,4 +2,5 @@
|
|||
export { default as ErrorBoundary } from "./ErrorBoundary";
|
||||
export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay";
|
||||
export { LinkMemoDialog } from "./LinkMemoDialog";
|
||||
export { default as LinkPreviewDialog } from "./LinkPreviewDialog";
|
||||
export { LocationDialog } from "./LocationDialog";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { timestampDate } from "@bufbuild/protobuf/wkt";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { LinkPreview } from "@/components/memo-metadata";
|
||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||
import { instanceStore, memoStore, userStore } from "@/store";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
|
|
@ -7,6 +8,7 @@ import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_p
|
|||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { convertVisibilityFromString } from "@/utils/memo";
|
||||
import type { EditorRefActions } from "../Editor";
|
||||
import { extractLinkPreviewsFromContent } from "../utils/linkPreviewSerializer";
|
||||
|
||||
export interface UseMemoEditorInitOptions {
|
||||
editorRef: React.RefObject<EditorRefActions>;
|
||||
|
|
@ -19,6 +21,7 @@ export interface UseMemoEditorInitOptions {
|
|||
onAttachmentsChange: (attachments: Attachment[]) => void;
|
||||
onRelationsChange: (relations: MemoRelation[]) => void;
|
||||
onLocationChange: (location: Location | undefined) => void;
|
||||
onLinkPreviewsChange?: (previews: LinkPreview[]) => void;
|
||||
}
|
||||
|
||||
export interface UseMemoEditorInitReturn {
|
||||
|
|
@ -49,7 +52,11 @@ export const useMemoEditorInit = (options: UseMemoEditorInitOptions): UseMemoEdi
|
|||
|
||||
// Initialize content cache
|
||||
useEffect(() => {
|
||||
editorRef.current?.setContent(contentCache || "");
|
||||
const { cleanedContent, previews } = extractLinkPreviewsFromContent(contentCache || "");
|
||||
if (previews.length > 0) {
|
||||
options.onLinkPreviewsChange?.(previews);
|
||||
}
|
||||
editorRef.current?.setContent(cleanedContent || "");
|
||||
}, []);
|
||||
|
||||
// Auto-focus if requested
|
||||
|
|
@ -88,7 +95,11 @@ export const useMemoEditorInit = (options: UseMemoEditorInitOptions): UseMemoEdi
|
|||
onRelationsChange(memo.relations);
|
||||
onLocationChange(memo.location);
|
||||
if (!contentCache) {
|
||||
editorRef.current?.setContent(memo.content ?? "");
|
||||
const { cleanedContent, previews } = extractLinkPreviewsFromContent(memo.content ?? "");
|
||||
if (previews.length > 0) {
|
||||
options.onLinkPreviewsChange?.(previews);
|
||||
}
|
||||
editorRef.current?.setContent(cleanedContent);
|
||||
}
|
||||
}
|
||||
}, [memoName]);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import type { LinkPreview } from "@/components/memo-metadata";
|
||||
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
|
||||
import type { Location, MemoRelation } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
|
||||
|
|
@ -7,6 +8,7 @@ interface MemoEditorState {
|
|||
memoVisibility: Visibility;
|
||||
attachmentList: Attachment[];
|
||||
relationList: MemoRelation[];
|
||||
linkPreviews: LinkPreview[];
|
||||
location: Location | undefined;
|
||||
isFocusMode: boolean;
|
||||
isUploadingAttachment: boolean;
|
||||
|
|
@ -28,6 +30,7 @@ export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PR
|
|||
isFocusMode: false,
|
||||
attachmentList: [],
|
||||
relationList: [],
|
||||
linkPreviews: [],
|
||||
location: undefined,
|
||||
isUploadingAttachment: false,
|
||||
isRequesting: false,
|
||||
|
|
@ -49,6 +52,10 @@ export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PR
|
|||
setState((prev) => ({ ...prev, relationList: v }));
|
||||
}, []);
|
||||
|
||||
const setLinkPreviews = useCallback((v: LinkPreview[]) => {
|
||||
setState((prev) => ({ ...prev, linkPreviews: v }));
|
||||
}, []);
|
||||
|
||||
const setLocation = useCallback((v: Location | undefined) => {
|
||||
setState((prev) => ({ ...prev, location: v }));
|
||||
}, []);
|
||||
|
|
@ -79,6 +86,7 @@ export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PR
|
|||
isRequesting: false,
|
||||
attachmentList: [],
|
||||
relationList: [],
|
||||
linkPreviews: [],
|
||||
location: undefined,
|
||||
isDraggingFile: false,
|
||||
}));
|
||||
|
|
@ -89,6 +97,7 @@ export const useMemoEditorState = (initialVisibility: Visibility = Visibility.PR
|
|||
setMemoVisibility,
|
||||
setAttachmentList,
|
||||
setRelationList,
|
||||
setLinkPreviews,
|
||||
setLocation,
|
||||
toggleFocusMode,
|
||||
setUploadingAttachment,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { extractMemoIdFromName } from "@/store/common";
|
|||
import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import DateTimeInput from "../DateTimeInput";
|
||||
import { AttachmentList, LocationDisplay, RelationList } from "../memo-metadata";
|
||||
import { AttachmentList, type LinkPreview, LinkPreviewList, LocationDisplay, RelationList } from "../memo-metadata";
|
||||
import { ErrorBoundary, FocusModeExitButton, FocusModeOverlay } from "./components";
|
||||
import { FOCUS_MODE_STYLES, LOCALSTORAGE_DEBOUNCE_DELAY } from "./constants";
|
||||
import Editor, { type EditorRefActions } from "./Editor";
|
||||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import InsertMenu from "./Toolbar/InsertMenu";
|
||||
import VisibilitySelector from "./Toolbar/VisibilitySelector";
|
||||
import { MemoEditorContext } from "./types";
|
||||
import { appendLinkPreviewsToContent } from "./utils/linkPreviewSerializer";
|
||||
|
||||
export interface Props {
|
||||
className?: string;
|
||||
|
|
@ -62,6 +63,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
memoVisibility,
|
||||
attachmentList,
|
||||
relationList,
|
||||
linkPreviews,
|
||||
location,
|
||||
isFocusMode,
|
||||
isUploadingAttachment,
|
||||
|
|
@ -71,6 +73,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
setMemoVisibility,
|
||||
setAttachmentList,
|
||||
setRelationList,
|
||||
setLinkPreviews,
|
||||
setLocation,
|
||||
toggleFocusMode,
|
||||
setUploadingAttachment,
|
||||
|
|
@ -103,6 +106,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
onAttachmentsChange: setAttachmentList,
|
||||
onRelationsChange: setRelationList,
|
||||
onLocationChange: setLocation,
|
||||
onLinkPreviewsChange: setLinkPreviews,
|
||||
});
|
||||
|
||||
// Memo save hook - handles create/update logic
|
||||
|
|
@ -131,7 +135,8 @@ const MemoEditor = observer((props: Props) => {
|
|||
return;
|
||||
}
|
||||
const content = editorRef.current?.getContent() ?? "";
|
||||
await saveMemo(content, {
|
||||
const contentWithPreviews = appendLinkPreviewsToContent(content, linkPreviews);
|
||||
await saveMemo(contentWithPreviews, {
|
||||
memoName,
|
||||
parentMemoName,
|
||||
visibility: memoVisibility,
|
||||
|
|
@ -150,6 +155,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
memoVisibility,
|
||||
attachmentList,
|
||||
relationList,
|
||||
linkPreviews,
|
||||
location,
|
||||
localFiles,
|
||||
createTime,
|
||||
|
|
@ -203,6 +209,20 @@ const MemoEditor = observer((props: Props) => {
|
|||
return relationList.filter((relation) => relation.type === MemoRelation_Type.REFERENCE);
|
||||
}, [memoName, relationList]);
|
||||
|
||||
const handleAddLinkPreview = useCallback(
|
||||
(preview: LinkPreview) => {
|
||||
setLinkPreviews([preview, ...linkPreviews.filter((item) => item.url !== preview.url)]);
|
||||
},
|
||||
[linkPreviews, setLinkPreviews],
|
||||
);
|
||||
|
||||
const handleRemoveLinkPreview = useCallback(
|
||||
(id: string) => {
|
||||
setLinkPreviews(linkPreviews.filter((item) => item.id !== id));
|
||||
},
|
||||
[linkPreviews, setLinkPreviews],
|
||||
);
|
||||
|
||||
const editorConfig = useMemo(
|
||||
() => ({
|
||||
className: "",
|
||||
|
|
@ -221,7 +241,10 @@ const MemoEditor = observer((props: Props) => {
|
|||
[i18n.language, isFocusMode, isComposing, handlePasteEvent, handleCompositionStart, handleCompositionEnd, saveContentToCache],
|
||||
);
|
||||
|
||||
const allowSave = (hasContent || attachmentList.length > 0 || localFiles.length > 0) && !isUploadingAttachment && !isRequesting;
|
||||
const allowSave =
|
||||
(hasContent || attachmentList.length > 0 || localFiles.length > 0 || linkPreviews.length > 0) &&
|
||||
!isUploadingAttachment &&
|
||||
!isRequesting;
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
|
|
@ -266,6 +289,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
localFiles={localFiles}
|
||||
onRemoveLocalFile={removeFile}
|
||||
/>
|
||||
<LinkPreviewList mode="edit" previews={linkPreviews} onRemove={handleRemoveLinkPreview} />
|
||||
<RelationList mode="edit" relations={referenceRelations} onRelationsChange={setRelationList} />
|
||||
<div className="relative w-full flex flex-row justify-between items-center pt-2 gap-2" onFocus={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-row justify-start items-center gap-1">
|
||||
|
|
@ -273,6 +297,7 @@ const MemoEditor = observer((props: Props) => {
|
|||
isUploading={isUploadingAttachment}
|
||||
location={location}
|
||||
onLocationChange={setLocation}
|
||||
onLinkPreviewAdd={handleAddLinkPreview}
|
||||
onToggleFocusMode={toggleFocusMode}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import type { LinkPreview } from "@/components/memo-metadata";
|
||||
|
||||
const PREVIEW_REGEX = /<div[^>]*data-memo-link-preview=["']true["'][^>]*><\/div>/gi;
|
||||
|
||||
export function serializeLinkPreviews(previews: LinkPreview[]): string {
|
||||
return previews
|
||||
.map((preview) => {
|
||||
const attrs = [
|
||||
`data-memo-link-preview="true"`,
|
||||
`data-id="${escapeAttribute(preview.id)}"`,
|
||||
`data-url="${escapeAttribute(preview.url)}"`,
|
||||
`data-title="${escapeAttribute(preview.title)}"`,
|
||||
`data-description="${escapeAttribute(preview.description)}"`,
|
||||
`data-image="${escapeAttribute(preview.imageUrl)}"`,
|
||||
`data-site="${escapeAttribute(preview.siteName || "")}"`,
|
||||
];
|
||||
return `<div class="memo-link-preview" ${attrs.join(" ")}></div>`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function appendLinkPreviewsToContent(content: string, previews: LinkPreview[]): string {
|
||||
if (previews.length === 0) return content;
|
||||
const serialized = serializeLinkPreviews(previews);
|
||||
const trimmedContent = content.trimEnd();
|
||||
if (!trimmedContent.trim()) return serialized;
|
||||
return `${trimmedContent}\n\n${serialized}`;
|
||||
}
|
||||
|
||||
export function extractLinkPreviewsFromContent(content: string): { cleanedContent: string; previews: LinkPreview[] } {
|
||||
const matches = content.match(PREVIEW_REGEX) || [];
|
||||
const previews: LinkPreview[] = matches.map((snippet) => parsePreviewSnippet(snippet)).filter(Boolean) as LinkPreview[];
|
||||
const cleanedContent = content.replace(PREVIEW_REGEX, "").trimEnd();
|
||||
return { cleanedContent, previews };
|
||||
}
|
||||
|
||||
function parsePreviewSnippet(snippet: string): LinkPreview | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = snippet;
|
||||
const el = container.firstElementChild as HTMLElement | null;
|
||||
if (!el) return null;
|
||||
|
||||
return {
|
||||
id: el.getAttribute("data-id") || cryptoId(),
|
||||
url: unescapeAttribute(el.getAttribute("data-url") || ""),
|
||||
title: unescapeAttribute(el.getAttribute("data-title") || "Link preview"),
|
||||
description: unescapeAttribute(el.getAttribute("data-description") || ""),
|
||||
imageUrl: unescapeAttribute(el.getAttribute("data-image") || ""),
|
||||
siteName: unescapeAttribute(el.getAttribute("data-site") || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function unescapeAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
|
||||
function cryptoId(): string {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { Globe2Icon, SparklesIcon, XIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DisplayMode, LinkPreview } from "./types";
|
||||
|
||||
interface LinkPreviewCardProps {
|
||||
preview: LinkPreview;
|
||||
mode: DisplayMode;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LinkPreviewCard = ({ preview, mode, onRemove, className }: LinkPreviewCardProps) => {
|
||||
const hostname = getHostname(preview.url);
|
||||
|
||||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) =>
|
||||
preview.url ? (
|
||||
<a href={preview.url} target="_blank" rel="noopener noreferrer" className="no-underline">
|
||||
{children}
|
||||
</a>
|
||||
) : (
|
||||
<div>{children}</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full gap-3 rounded-md border bg-background/80 p-2 transition-colors",
|
||||
"hover:border-muted-foreground/60 hover:bg-accent/40",
|
||||
preview.url && "cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="relative h-24 w-28 shrink-0 overflow-hidden rounded-md border bg-muted/60">
|
||||
<img src={preview.imageUrl} alt={preview.title} className="h-full w-full object-cover" loading="lazy" />
|
||||
{hostname && (
|
||||
<span className="absolute bottom-1 left-1 rounded border bg-background/90 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{preview.siteName || hostname}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="line-clamp-2 text-sm font-semibold leading-5">{preview.title}</p>
|
||||
<p className="line-clamp-2 text-xs leading-4 text-muted-foreground">{preview.description}</p>
|
||||
</div>
|
||||
{mode === "edit" && onRemove && (
|
||||
<button
|
||||
className="rounded-sm p-1 text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onRemove();
|
||||
}}
|
||||
aria-label="Remove preview"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 truncate text-xs text-primary">
|
||||
<Globe2Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{preview.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
function getHostname(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default LinkPreviewCard;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { SparklesIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import LinkPreviewCard from "./LinkPreviewCard";
|
||||
import type { BaseMetadataProps, LinkPreview } from "./types";
|
||||
|
||||
interface LinkPreviewListProps extends BaseMetadataProps {
|
||||
previews: LinkPreview[];
|
||||
onRemove?: (id: string) => void;
|
||||
}
|
||||
|
||||
const LinkPreviewList = ({ previews, mode, onRemove, className }: LinkPreviewListProps) => {
|
||||
const t = useTranslate();
|
||||
|
||||
if (previews.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("mt-2 flex w-full flex-col gap-2", className)}>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
<span>{t("editor.link-preview")}</span>
|
||||
</div>
|
||||
|
||||
{previews.map((preview) => (
|
||||
<LinkPreviewCard key={preview.id} preview={preview} mode={mode} onRemove={onRemove ? () => onRemove(preview.id) : undefined} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkPreviewList;
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
export { default as AttachmentCard } from "./AttachmentCard";
|
||||
export { default as AttachmentList } from "./AttachmentList";
|
||||
export { default as LinkPreviewCard } from "./LinkPreviewCard";
|
||||
export { default as LinkPreviewList } from "./LinkPreviewList";
|
||||
export { default as LocationDisplay } from "./LocationDisplay";
|
||||
|
||||
// Base components (can be used for other metadata types)
|
||||
|
|
@ -8,5 +10,5 @@ export { default as RelationCard } from "./RelationCard";
|
|||
export { default as RelationList } from "./RelationList";
|
||||
|
||||
// Types
|
||||
export type { AttachmentItem, BaseMetadataProps, DisplayMode, FileCategory, LocalFile } from "./types";
|
||||
export type { AttachmentItem, BaseMetadataProps, DisplayMode, FileCategory, LinkPreview, LocalFile } from "./types";
|
||||
export { attachmentToItem, fileToItem, filterByCategory, separateMediaAndDocs, toAttachmentItems } from "./types";
|
||||
|
|
|
|||
|
|
@ -62,6 +62,15 @@ export interface LocalFile {
|
|||
readonly previewUrl: string;
|
||||
}
|
||||
|
||||
export interface LinkPreview {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] {
|
||||
return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,14 @@
|
|||
"save": "Save",
|
||||
"no-changes-detected": "No changes detected",
|
||||
"focus-mode": "Focus Mode",
|
||||
"exit-focus-mode": "Exit Focus Mode"
|
||||
"exit-focus-mode": "Exit Focus Mode",
|
||||
"link-preview": "Link preview",
|
||||
"scrape-link": "Scrape link preview",
|
||||
"scrape-link-description": "Paste a URL to fetch its title, description, and cover image.",
|
||||
"link-preview-empty": "Paste a link to generate a preview card.",
|
||||
"link-preview-invalid": "Please enter a valid URL.",
|
||||
"link-preview-fetch-error": "Failed to fetch preview. Please try again.",
|
||||
"link-preview-timeout": "Request timed out. Please retry."
|
||||
},
|
||||
"filters": {
|
||||
"has-code": "hasCode",
|
||||
|
|
|
|||
|
|
@ -114,7 +114,14 @@
|
|||
"add-your-comment-here": "Tambahkan komentar Anda di sini...",
|
||||
"any-thoughts": "Punya pemikiran...",
|
||||
"save": "Simpan",
|
||||
"no-changes-detected": "Tidak ada perubahan yang terdeteksi"
|
||||
"no-changes-detected": "Tidak ada perubahan yang terdeteksi",
|
||||
"link-preview": "Pratinjau tautan",
|
||||
"scrape-link": "Ambil pratinjau tautan",
|
||||
"scrape-link-description": "Tempel URL untuk mengambil judul, deskripsi, dan gambar sampul.",
|
||||
"link-preview-empty": "Tempel tautan untuk membuat kartu pratinjau.",
|
||||
"link-preview-invalid": "Masukkan URL yang valid.",
|
||||
"link-preview-fetch-error": "Gagal mengambil pratinjau. Silakan coba lagi.",
|
||||
"link-preview-timeout": "Permintaan habis waktu. Silakan coba lagi."
|
||||
},
|
||||
"filters": {
|
||||
"has-code": "Memiliki kode",
|
||||
|
|
|
|||
Loading…
Reference in New Issue