diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index af27388e..b0aa85c5 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -123,6 +123,7 @@ checksums: Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058 Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6 Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5 + Convert%20to%20link/singular: 66210d2889031426c07f0b2c6c4c09d7 Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2 Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178 Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a @@ -183,14 +184,17 @@ checksums: Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487 Due%20today/singular: a14cb9dd0003485894d328bb803c80e5 Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97 + Edit/singular: eee7f39ff90b18852afc1671f21fbaa9 Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374 Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03 Edit%20workspace%20URL/singular: bbae5f2f8a442947d33099979bbbe899 + Edit%20YouTube%20Video/singular: 4899d9e990d291eb6e71ee40a8ee314b Editing/singular: 3449a7988cd69207b7c6929af1f4abf1 email/singular: f31eb214738e037d58e26149797739df Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5 Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31 + Enter%20a%20custom%20title/singular: f002074db0bd51d4f28d2736e140370a Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651 Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64 Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e @@ -220,6 +224,7 @@ checksums: Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4 Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7 Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495 + Failed%20to%20fetch%20video%20information/singular: 1e3fd610e8ed3e6c4c66e6ce88fc8b7a Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297 FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c @@ -391,6 +396,7 @@ checksums: Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932 Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f + Please%20enter%20a%20valid%20YouTube%20URL/singular: c16c69c3b742b1e19148378d50adf37f Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844 Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98 @@ -496,6 +502,7 @@ checksums: This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6 This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c + Title/singular: 344e64395eaff6822a57d18623853e1a To%20Do/singular: d60813ea824f373462471e092d136eed Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36 Track%20all%20card%20changes%20with%20detailed%20activity%20history./singular: 0d3bac559c71ec4b8734f9f212320de5 @@ -617,3 +624,4 @@ checksums: Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292 Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322 Your%20workspace%20slug%20has%20been%20updated./singular: c808949b9b2b4a9aba2472f5d1050167 + YouTube%20URL/singular: 0b48896061a1124501fdaba026804148 diff --git a/apps/web/src/components/Editor.tsx b/apps/web/src/components/Editor.tsx index 41c91f11..ecfaf452 100644 --- a/apps/web/src/components/Editor.tsx +++ b/apps/web/src/components/Editor.tsx @@ -1,3 +1,4 @@ +import type { Range as TiptapRange } from "@tiptap/core"; import type { Editor as TiptapEditor } from "@tiptap/react"; import type { SuggestionKeyDownProps, @@ -44,6 +45,7 @@ import { Markdown } from "tiptap-markdown"; import { getAvatarUrl } from "~/utils/helpers"; import Avatar from "./Avatar"; +import { YouTubeNode } from "./YouTubeEmbed/YouTubeNode"; declare module "@tiptap/core" { interface Commands { @@ -56,7 +58,7 @@ declare module "@tiptap/core" { export interface SlashCommandItem { title: string; icon?: React.ReactNode; - command?: (props: { editor: TiptapEditor; range: Range }) => void; + command?: (props: { editor: TiptapEditor; range: TiptapRange }) => void; disabled?: boolean; } @@ -431,12 +433,14 @@ export default function Editor({ onBlur, readOnly = false, workspaceMembers, + enableYouTubeEmbed = true, }: { content: string | null; onChange?: (value: string) => void; onBlur?: () => void; readOnly?: boolean; workspaceMembers: WorkspaceMember[]; + enableYouTubeEmbed?: boolean; }) { const containerRef = useRef(null); @@ -484,10 +488,17 @@ export default function Editor({ }), ); const q = query.toLowerCase(); - return all.filter((u) => u.label.toLowerCase().includes(q)); + return all.filter( + (u) => + u.label && + typeof u.label === "string" && + u.label.toLowerCase().includes(q), + ); }, - command: ({ editor, range, props }: any) => { - const mentionHTML = `@${props.label} `; + command: ({ editor, range, props }) => { + const id = props.id ?? ""; + const label = props.label ?? ""; + const mentionHTML = `@${label} `; editor .chain() @@ -503,6 +514,7 @@ export default function Editor({ return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; }, }), + ...(enableYouTubeEmbed ? [YouTubeNode] : []), ], content, onUpdate: ({ editor }) => onChange?.(editor.getHTML()), @@ -559,6 +571,9 @@ export default function Editor({ text-decoration: none; font-weight: 500; } + .tiptap [data-youtube] { + margin: 1rem 0; + } `} {!readOnly && editor && } void; +} + +export function EditYouTubeModal() { + const { closeModal, getModalState } = useModal(); + const [isValidating, setIsValidating] = useState(false); + const [urlError, setUrlError] = useState(null); + + // Get initial values and callback from modal state + const modalState = getModalState("EDIT_YOUTUBE") as + | EditYouTubeModalState + | undefined; + const initialUrl = modalState?.url ?? ""; + const initialTitle = modalState?.title ?? ""; + const onSave = modalState?.onSave; + + const { register, handleSubmit, watch, reset } = + useForm({ + defaultValues: { + url: initialUrl, + title: initialTitle, + }, + }); + + // Reset form when modal state changes (when modal opens with new values) + useEffect(() => { + if (modalState) { + reset({ + url: modalState.url, + title: modalState.title, + }); + } + }, [modalState, reset]); + + const currentUrl = watch("url"); + + const onSubmit = async (values: EditYouTubeFormInput) => { + // Validate URL + if (!isYouTubeUrl(values.url)) { + setUrlError(t`Please enter a valid YouTube URL`); + return; + } + + setUrlError(null); + setIsValidating(true); + + try { + // If title is empty and URL changed, fetch new title + let finalTitle = values.title; + if (!finalTitle.trim() && values.url !== initialUrl) { + const metadata = await fetchYouTubeMetadata(values.url); + finalTitle = metadata?.title ?? "YouTube Video"; + } else if (!finalTitle.trim()) { + // Keep existing title if no new title provided and URL unchanged + finalTitle = initialTitle || "YouTube Video"; + } + + if (onSave) { + onSave(values.url, finalTitle); + } + closeModal(); + } catch (error) { + console.error(error); + setUrlError(t`Failed to fetch video information`); + } finally { + setIsValidating(false); + } + }; + + // Auto-focus on title input (more useful for editing) + useEffect(() => { + const titleElement: HTMLElement | null = + document.querySelector("#youtube-title"); + if (titleElement) titleElement.focus(); + }, []); + + // Validate URL on change + useEffect(() => { + if (currentUrl && !isYouTubeUrl(currentUrl)) { + setUrlError(t`Please enter a valid YouTube URL`); + } else { + setUrlError(null); + } + }, [currentUrl]); + + return ( +
+
+
+

{t`Edit YouTube Video`}

+ +
+ +
+
+ + { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} + /> +
+ +
+ + { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} + /> + {urlError && ( +

+ {urlError} +

+ )} +
+
+
+ +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/YouTubeEmbed/YouTubeCard.tsx b/apps/web/src/components/YouTubeEmbed/YouTubeCard.tsx new file mode 100644 index 00000000..7dcfaa07 --- /dev/null +++ b/apps/web/src/components/YouTubeEmbed/YouTubeCard.tsx @@ -0,0 +1,53 @@ +import YouTubeDropdown from "./YouTubeDropdown"; + +interface YouTubeCardProps { + videoId: string; + url: string; + title: string; + showEmbed?: boolean; + onConvertToLink: () => void; + onDelete: () => void; + onUpdate: (url: string, title: string) => void; +} + +const YouTubeCard = ({ + videoId, + url, + title, + showEmbed = true, + onConvertToLink, + onDelete, + onUpdate, +}: YouTubeCardProps) => { + return ( +
+
+

{title}

+
+ +
+
+ + {showEmbed && videoId && ( +
+