Skip to content

Commit 89c8961

Browse files
feat: add YouTube video embed support for TipTap editor (#294)
* feat: add YouTube video embed support for TipTap editor - Paste YouTube URLs to auto-embed videos with metadata - Edit video title and URL through dropdown menu - Convert embed back to plain URL link - Fetch video titles automatically via YouTube oEmbed API * fix: - reverted NewCardForm CSS properties back to max-h-48. - Added boolean prop to TipTap Edtior for YouTube embed. - Disabled YouTube embed feature for NewCardForm Editor * chore: lint * chore: translations * chore: update translations --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
1 parent a5432c6 commit 89c8961

29 files changed

+1570
-489
lines changed

apps/web/i18n.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ checksums:
123123
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
124124
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
125125
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
126+
Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7
126127
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
127128
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
128129
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
@@ -183,14 +184,17 @@ checksums:
183184
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
184185
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
185186
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
187+
Edit/singular: eee7f39ff90b18852afc1671f21fbaa9
186188
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
187189
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
188190
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
189191
Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899
192+
Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b
190193
Editing/singular: 3449a7988cd69207b7c6929af1f4abf1
191194
email/singular: f31eb214738e037d58e26149797739df
192195
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
193196
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
197+
Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a
194198
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
195199
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
196200
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
@@ -220,6 +224,7 @@ checksums:
220224
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
221225
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
222226
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
227+
Failed%20to%20fetch%20video%20information/singular: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a
223228
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
224229
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
225230
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
@@ -391,6 +396,7 @@ checksums:
391396
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
392397
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
393398
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
399+
Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f
394400
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
395401
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
396402
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
@@ -496,6 +502,7 @@ checksums:
496502
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
497503
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
498504
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
505+
Title/singular: 344e64395eaff6822a57d18623853e1a
499506
To%20Do/singular: d60813ea824f373462471e092d136eed
500507
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
501508
Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5
@@ -617,3 +624,4 @@ checksums:
617624
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
618625
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322
619626
Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167
627+
YouTube%20URL/singular: 0b48896061a1124501fdaba026804148

apps/web/src/components/Editor.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Range as TiptapRange } from "@tiptap/core";
12
import type { Editor as TiptapEditor } from "@tiptap/react";
23
import type {
34
SuggestionKeyDownProps,
@@ -44,6 +45,7 @@ import { Markdown } from "tiptap-markdown";
4445

4546
import { getAvatarUrl } from "~/utils/helpers";
4647
import Avatar from "./Avatar";
48+
import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode";
4749

4850
declare module "@tiptap/core" {
4951
interface Commands<ReturnType> {
@@ -56,7 +58,7 @@ declare module "@tiptap/core" {
5658
export interface SlashCommandItem {
5759
title: string;
5860
icon?: React.ReactNode;
59-
command?: (props: { editor: TiptapEditor; range: Range }) => void;
61+
command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void;
6062
disabled?: boolean;
6163
}
6264

@@ -431,12 +433,14 @@ export default function Editor({
431433
onBlur,
432434
readOnly = false,
433435
workspaceMembers,
436+
enableYouTubeEmbed = true,
434437
}: {
435438
content: string | null;
436439
onChange?: (value: string) => void;
437440
onBlur?: () => void;
438441
readOnly?: boolean;
439442
workspaceMembers: WorkspaceMember[];
443+
enableYouTubeEmbed?: boolean;
440444
}) {
441445
const containerRef = useRef<HTMLDivElement>(null);
442446

@@ -484,10 +488,17 @@ export default function Editor({
484488
}),
485489
);
486490
const q = query.toLowerCase();
487-
return all.filter((u) => u.label.toLowerCase().includes(q));
491+
return all.filter(
492+
(u) =>
493+
u.label &&
494+
typeof u.label === "string" &&
495+
u.label.toLowerCase().includes(q),
496+
);
488497
},
489-
command: ({ editor, range, props }: any) => {
490-
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span>&nbsp;`;
498+
command: ({ editor, range, props }) => {
499+
const id = props.id ?? "";
500+
const label = props.label ?? "";
501+
const mentionHTML = `<span data-type="mention" data-id="${id}" data-label="${label}">@${label}</span>&nbsp;`;
491502

492503
editor
493504
.chain()
@@ -503,6 +514,7 @@ export default function Editor({
503514
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
504515
},
505516
}),
517+
...(enableYouTubeEmbed ? [YouTubeNode] : []),
506518
],
507519
content,
508520
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
@@ -559,6 +571,9 @@ export default function Editor({
559571
text-decoration: none;
560572
font-weight: 500;
561573
}
574+
.tiptap [data-youtube] {
575+
margin: 1rem 0;
576+
}
562577
`}</style>
563578
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
564579
<EditorContent
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { t } from "@lingui/core/macro";
2+
import { useEffect, useState } from "react";
3+
import { useForm } from "react-hook-form";
4+
import { HiXMark } from "react-icons/hi2";
5+
6+
import Button from "~/components/Button";
7+
import Input from "~/components/Input";
8+
import { useModal } from "~/providers/modal";
9+
import { fetchYouTubeMetadata, isYouTubeUrl } from "./utils";
10+
11+
interface EditYouTubeFormInput {
12+
url: string;
13+
title: string;
14+
}
15+
16+
interface EditYouTubeModalState {
17+
url: string;
18+
title: string;
19+
onSave: (url: string, title: string) => void;
20+
}
21+
22+
export function EditYouTubeModal() {
23+
const { closeModal, getModalState } = useModal();
24+
const [isValidating, setIsValidating] = useState(false);
25+
const [urlError, setUrlError] = useState<string | null>(null);
26+
27+
// Get initial values and callback from modal state
28+
const modalState = getModalState("EDIT_YOUTUBE") as
29+
| EditYouTubeModalState
30+
| undefined;
31+
const initialUrl = modalState?.url ?? "";
32+
const initialTitle = modalState?.title ?? "";
33+
const onSave = modalState?.onSave;
34+
35+
const { register, handleSubmit, watch, reset } =
36+
useForm<EditYouTubeFormInput>({
37+
defaultValues: {
38+
url: initialUrl,
39+
title: initialTitle,
40+
},
41+
});
42+
43+
// Reset form when modal state changes (when modal opens with new values)
44+
useEffect(() => {
45+
if (modalState) {
46+
reset({
47+
url: modalState.url,
48+
title: modalState.title,
49+
});
50+
}
51+
}, [modalState, reset]);
52+
53+
const currentUrl = watch("url");
54+
55+
const onSubmit = async (values: EditYouTubeFormInput) => {
56+
// Validate URL
57+
if (!isYouTubeUrl(values.url)) {
58+
setUrlError(t`Please enter a valid YouTube URL`);
59+
return;
60+
}
61+
62+
setUrlError(null);
63+
setIsValidating(true);
64+
65+
try {
66+
// If title is empty and URL changed, fetch new title
67+
let finalTitle = values.title;
68+
if (!finalTitle.trim() && values.url !== initialUrl) {
69+
const metadata = await fetchYouTubeMetadata(values.url);
70+
finalTitle = metadata?.title ?? "YouTube Video";
71+
} else if (!finalTitle.trim()) {
72+
// Keep existing title if no new title provided and URL unchanged
73+
finalTitle = initialTitle || "YouTube Video";
74+
}
75+
76+
if (onSave) {
77+
onSave(values.url, finalTitle);
78+
}
79+
closeModal();
80+
} catch (error) {
81+
console.error(error);
82+
setUrlError(t`Failed to fetch video information`);
83+
} finally {
84+
setIsValidating(false);
85+
}
86+
};
87+
88+
// Auto-focus on title input (more useful for editing)
89+
useEffect(() => {
90+
const titleElement: HTMLElement | null =
91+
document.querySelector<HTMLElement>("#youtube-title");
92+
if (titleElement) titleElement.focus();
93+
}, []);
94+
95+
// Validate URL on change
96+
useEffect(() => {
97+
if (currentUrl && !isYouTubeUrl(currentUrl)) {
98+
setUrlError(t`Please enter a valid YouTube URL`);
99+
} else {
100+
setUrlError(null);
101+
}
102+
}, [currentUrl]);
103+
104+
return (
105+
<form onSubmit={handleSubmit(onSubmit)}>
106+
<div className="px-5 pt-5">
107+
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
108+
<h2 className="text-sm font-medium">{t`Edit YouTube Video`}</h2>
109+
<button
110+
type="button"
111+
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
112+
onClick={(e) => {
113+
e.preventDefault();
114+
closeModal();
115+
}}
116+
>
117+
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
118+
</button>
119+
</div>
120+
121+
<div className="space-y-4">
122+
<div>
123+
<label
124+
htmlFor="youtube-title"
125+
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
126+
>
127+
{t`Title`}
128+
</label>
129+
<Input
130+
id="youtube-title"
131+
placeholder={t`Enter a custom title`}
132+
{...register("title")}
133+
onKeyDown={async (e) => {
134+
if (e.key === "Enter") {
135+
e.preventDefault();
136+
await handleSubmit(onSubmit)();
137+
}
138+
}}
139+
/>
140+
</div>
141+
142+
<div>
143+
<label
144+
htmlFor="youtube-url"
145+
className="mb-2 block text-xs font-medium text-light-900 dark:text-dark-900"
146+
>
147+
{t`YouTube URL`}
148+
</label>
149+
<Input
150+
id="youtube-url"
151+
placeholder={t`https://www.youtube.com/watch?v=...`}
152+
{...register("url", { required: true })}
153+
onKeyDown={async (e) => {
154+
if (e.key === "Enter") {
155+
e.preventDefault();
156+
await handleSubmit(onSubmit)();
157+
}
158+
}}
159+
/>
160+
{urlError && (
161+
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
162+
{urlError}
163+
</p>
164+
)}
165+
</div>
166+
</div>
167+
</div>
168+
169+
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
170+
<div className="space-x-2">
171+
<Button
172+
type="button"
173+
variant="secondary"
174+
onClick={() => closeModal()}
175+
>
176+
{t`Cancel`}
177+
</Button>
178+
<Button
179+
type="submit"
180+
isLoading={isValidating}
181+
disabled={!watch("url") || !!urlError}
182+
>
183+
{t`Save`}
184+
</Button>
185+
</div>
186+
</div>
187+
</form>
188+
);
189+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import YouTubeDropdown from "./YouTubeDropdown";
2+
3+
interface YouTubeCardProps {
4+
videoId: string;
5+
url: string;
6+
title: string;
7+
showEmbed?: boolean;
8+
onConvertToLink: () => void;
9+
onDelete: () => void;
10+
onUpdate: (url: string, title: string) => void;
11+
}
12+
13+
const YouTubeCard = ({
14+
videoId,
15+
url,
16+
title,
17+
showEmbed = true,
18+
onConvertToLink,
19+
onDelete,
20+
onUpdate,
21+
}: YouTubeCardProps) => {
22+
return (
23+
<div className="w-full max-w-md rounded-lg border border-light-300 bg-light-50 dark:border-dark-300 dark:bg-dark-50">
24+
<div className="flex items-center justify-between gap-6 p-0 px-6">
25+
<h3 className="truncate text-sm font-medium">{title}</h3>
26+
<div className="mt-3">
27+
<YouTubeDropdown
28+
url={url}
29+
title={title}
30+
onConvertToLink={onConvertToLink}
31+
onDelete={onDelete}
32+
onUpdate={onUpdate}
33+
/>
34+
</div>
35+
</div>
36+
37+
{showEmbed && videoId && (
38+
<div className="p-6 pt-1">
39+
<iframe
40+
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
41+
title={title}
42+
className="aspect-video w-full rounded-lg"
43+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
44+
referrerPolicy="strict-origin-when-cross-origin"
45+
allowFullScreen
46+
/>
47+
</div>
48+
)}
49+
</div>
50+
);
51+
};
52+
53+
export default YouTubeCard;

0 commit comments

Comments
 (0)