feat: youtube embeds

Renja 2 weeks ago
parent 29b683d5db
commit 41479c7adc
No known key found for this signature in database
GPG Key ID: 43531173B2DD8D7F

@ -1,6 +1,6 @@
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react"; import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { memo, useCallback, useState } from "react"; import { memo, useCallback, useState, useMemo } from "react";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useAsyncEffect from "@/hooks/useAsyncEffect"; import useAsyncEffect from "@/hooks/useAsyncEffect";
@ -20,6 +20,8 @@ import MemoEditor from "./MemoEditor";
import MemoLocationView from "./MemoLocationView"; import MemoLocationView from "./MemoLocationView";
import MemoReactionistView from "./MemoReactionListView"; import MemoReactionistView from "./MemoReactionListView";
import MemoRelationListView from "./MemoRelationListView"; import MemoRelationListView from "./MemoRelationListView";
import MemoYoutubeEmbedListView from "./MemoYoutubeEmbedListView";
import { extractYoutubeVideoIdsFromNodes } from "@/utils/youtube";
import { PreviewImageDialog } from "./PreviewImageDialog"; import { PreviewImageDialog } from "./PreviewImageDialog";
import ReactionSelector from "./ReactionSelector"; import ReactionSelector from "./ReactionSelector";
import UserAvatar from "./UserAvatar"; import UserAvatar from "./UserAvatar";
@ -65,6 +67,8 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
workspaceMemoRelatedSetting.enableBlurNsfwContent && workspaceMemoRelatedSetting.enableBlurNsfwContent &&
memo.tags?.some((tag) => workspaceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`))); memo.tags?.some((tag) => workspaceMemoRelatedSetting.nsfwTags.some((nsfwTag) => tag === nsfwTag || tag.startsWith(`${nsfwTag}/`)));
const youtubeVideoIds = useMemo(() => extractYoutubeVideoIdsFromNodes(memo.nodes), [memo.nodes]);
// Initial related data: creator. // Initial related data: creator.
useAsyncEffect(async () => { useAsyncEffect(async () => {
const user = await userStore.getOrFetchUserByName(memo.creator); const user = await userStore.getOrFetchUserByName(memo.creator);
@ -245,6 +249,7 @@ const MemoView: React.FC<Props> = observer((props: Props) => {
parentPage={parentPage} parentPage={parentPage}
/> />
{memo.location && <MemoLocationView location={memo.location} />} {memo.location && <MemoLocationView location={memo.location} />}
<MemoYoutubeEmbedListView videoIds={youtubeVideoIds} />
<MemoAttachmentListView attachments={memo.attachments} /> <MemoAttachmentListView attachments={memo.attachments} />
<MemoRelationListView memo={memo} relations={referencedMemos} parentPage={parentPage} /> <MemoRelationListView memo={memo} relations={referencedMemos} parentPage={parentPage} />
<MemoReactionistView memo={memo} reactions={memo.reactions} /> <MemoReactionistView memo={memo} reactions={memo.reactions} />

@ -0,0 +1,40 @@
import { memo } from "react";
import { cn } from "@/lib/utils";
interface Props {
videoIds: string[];
}
const MemoYoutubeEmbedListView: React.FC<Props> = ({ videoIds }: Props) => {
if (!videoIds || videoIds.length === 0) {
return null;
}
const EmbedCard = ({ videoId, className }: { videoId: string; className?: string }) => {
return (
<div className={cn("relative w-full", className)}>
<div className="relative w-full pt-[56.25%] rounded-lg overflow-hidden border border-border/60 bg-popover">
<iframe
className="absolute top-0 left-0 w-full h-full"
src={`https://www.youtube.com/embed/${videoId}`}
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</div>
</div>
);
};
return (
<div className="w-full flex flex-row justify-start overflow-auto gap-2">
{videoIds.map((id) => (
<div key={id} className="w-80 flex flex-col justify-start items-start shrink-0">
<EmbedCard videoId={id} className="max-h-64 grow" />
</div>
))}
</div>
);
};
export default memo(MemoYoutubeEmbedListView);

@ -0,0 +1,85 @@
import { Node, NodeType } from "@/types/proto/api/v1/markdown_service";
// Regular expressions to match various YouTube URL formats.
const YOUTUBE_REGEXPS: RegExp[] = [
/https?:\/\/(?:www\.)?youtube\.com\/watch\?v=([^&\s]+)/i,
/https?:\/\/(?:www\.)?youtu\.be\/([^?\s]+)/i,
/https?:\/\/(?:www\.)?youtube\.com\/shorts\/([^?\s]+)/i,
/https?:\/\/(?:www\.)?youtube\.com\/embed\/([^?\s]+)/i,
];
/**
* Extract the YouTube video ID from a given URL, if any.
* @param url The URL string to parse.
* @returns The video ID, or undefined if the URL is not a YouTube link.
*/
export const extractYoutubeIdFromUrl = (url: string): string | undefined => {
for (const regexp of YOUTUBE_REGEXPS) {
const match = url.match(regexp);
if (match?.[1]) {
return match[1];
}
}
return undefined;
};
/**
* Extract YouTube video IDs from markdown nodes.
* @param nodes The array of markdown nodes to extract YouTube video IDs from .
* @returns A deduplicated array of YouTube video IDs.
*/
export const extractYoutubeVideoIdsFromNodes = (nodes: Node[]): string[] => {
const ids = new Set<string>();
const isNodeArray = (value: unknown): value is Node[] =>
Array.isArray(value) &&
value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
"type" in (value[0] as Record<string, unknown>);
// Collect all child Node instances nested anywhere inside the given node
const collectChildren = (node: Node): Node[] => {
const collected: Node[] = [];
const queue: unknown[] = Object.values(node);
while (queue.length) {
const item = queue.shift();
if (!item) continue;
if (isNodeArray(item)) {
collected.push(...item);
continue;
}
if (Array.isArray(item)) {
queue.push(...item);
} else if (typeof item === "object") {
queue.push(...Object.values(item as Record<string, unknown>));
}
}
return collected;
};
const stack: Node[] = [...nodes];
while (stack.length) {
const node = stack.pop()!;
if (node.type === NodeType.LINK && node.linkNode) {
const id = extractYoutubeIdFromUrl(node.linkNode.url);
if (id) ids.add(id);
} else if (node.type === NodeType.AUTO_LINK && node.autoLinkNode) {
const id = extractYoutubeIdFromUrl(node.autoLinkNode.url);
if (id) ids.add(id);
}
const children = collectChildren(node);
if (children.length) {
stack.push(...children);
}
}
return [...ids];
};
Loading…
Cancel
Save