diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 3f5b3417..16c901f4 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -38,6 +38,7 @@ dependencies = [ "stripe>=11.1.1", "python-jose>=3.3.0", "logfire[sqlalchemy]>=3.8.0", + "beautifulsoup4>=4.13.4", ] [tool.ruff] diff --git a/apps/api/src/router.py b/apps/api/src/router.py index 54851a55..72e03402 100644 --- a/apps/api/src/router.py +++ b/apps/api/src/router.py @@ -10,6 +10,7 @@ from src.routers.ee import cloud_internal, payments from src.routers.install import install from src.services.dev.dev import isDevModeEnabledOrRaise from src.services.install.install import isInstallModeEnabled +from src.routers.utils import router as utils_router v1_router = APIRouter(prefix="/api/v1") @@ -61,3 +62,5 @@ v1_router.include_router( tags=["install"], dependencies=[Depends(isInstallModeEnabled)], ) + +v1_router.include_router(utils_router, prefix="/utils", tags=["utils"]) diff --git a/apps/api/src/routers/utils.py b/apps/api/src/routers/utils.py new file mode 100644 index 00000000..e95aaa1f --- /dev/null +++ b/apps/api/src/routers/utils.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, HTTPException, Query +from src.services.utils.link_preview import fetch_link_preview + +router = APIRouter() + +@router.get("/link-preview") +async def link_preview(url: str = Query(..., description="URL to preview")): + try: + data = await fetch_link_preview(url) + return data + except Exception as e: + raise HTTPException(status_code=400, detail=f"Failed to fetch link preview: {str(e)}") \ No newline at end of file diff --git a/apps/api/src/services/utils/link_preview.py b/apps/api/src/services/utils/link_preview.py new file mode 100644 index 00000000..dbfce73f --- /dev/null +++ b/apps/api/src/services/utils/link_preview.py @@ -0,0 +1,70 @@ +import httpx +from bs4 import BeautifulSoup, Tag +from typing import Optional, Dict +from urllib.parse import urljoin, urlparse + +async def fetch_link_preview(url: str) -> Dict[str, Optional[str]]: + async with httpx.AsyncClient(follow_redirects=True, timeout=10) as client: + response = await client.get(url) + response.raise_for_status() + html = response.text + + soup = BeautifulSoup(html, 'html.parser') + + def get_meta(property_name: str, attr: str = 'property') -> Optional[str]: + tag = soup.find('meta', attrs={attr: property_name}) + if tag and isinstance(tag, Tag) and tag.has_attr('content'): + content = tag['content'] + if isinstance(content, str): + return content + return None + + # Title + title = soup.title.string.strip() if soup.title and soup.title.string else None + # Description + description = get_meta('og:description') or get_meta('description', 'name') + # OG Image + og_image = get_meta('og:image') + if og_image and isinstance(og_image, str) and not og_image.startswith('http'): + og_image = urljoin(url, og_image) + # Favicon (robust) + favicon = None + icon_rels = [ + 'icon', + 'shortcut icon', + 'apple-touch-icon', + 'apple-touch-icon-precomposed', + ] + for link in soup.find_all('link'): + if not isinstance(link, Tag): + continue + rels = link.get('rel') + href = link.get('href') + if rels and href: + rels_lower = [r.lower() for r in rels] + if any(rel in rels_lower for rel in icon_rels): + if isinstance(href, str): + favicon = href + break + # Fallback to /favicon.ico if not found + if not favicon: + parsed = urlparse(url) + favicon = f"{parsed.scheme}://{parsed.netloc}/favicon.ico" + elif favicon and not favicon.startswith('http'): + favicon = urljoin(url, favicon) + # OG Title + og_title = get_meta('og:title') + # OG Type + og_type = get_meta('og:type') + # OG URL + og_url = get_meta('og:url') + + return { + 'title': og_title or title, + 'description': description, + 'og_image': og_image, + 'favicon': favicon, + 'og_type': og_type, + 'og_url': og_url or url, + 'url': url, + } \ No newline at end of file diff --git a/apps/api/uv.lock b/apps/api/uv.lock index f82a1c4c..da33ce1e 100644 --- a/apps/api/uv.lock +++ b/apps/api/uv.lock @@ -157,6 +157,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/b9/d51d34e6cd6d887adddb28a8680a1d34235cc45b9d6e238ce39b98199ca0/bcrypt-4.2.1-cp39-abi3-win_amd64.whl", hash = "sha256:e84e0e6f8e40a242b11bce56c313edc2be121cec3e0ec2d76fce01f6af33c07c", size = 153078 }, ] +[[package]] +name = "beautifulsoup4" +version = "4.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 }, +] + [[package]] name = "boto3" version = "1.36.22" @@ -999,6 +1012,7 @@ source = { virtual = "." } dependencies = [ { name = "alembic" }, { name = "alembic-postgresql-enum" }, + { name = "beautifulsoup4" }, { name = "boto3" }, { name = "botocore" }, { name = "chromadb" }, @@ -1037,6 +1051,7 @@ dependencies = [ requires-dist = [ { name = "alembic", specifier = ">=1.13.2" }, { name = "alembic-postgresql-enum", specifier = ">=1.2.0" }, + { name = "beautifulsoup4", specifier = ">=4.13.4" }, { name = "boto3", specifier = ">=1.34.79" }, { name = "botocore", specifier = ">=1.34.93" }, { name = "chromadb", specifier = "==0.5.16" }, @@ -1909,6 +1924,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "soupsieve" +version = "2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 }, +] + [[package]] name = "sqlalchemy" version = "2.0.38" diff --git a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index 9ce8ef2d..2057a11d 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -35,6 +35,7 @@ import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock' import { getLinkExtension } from '@components/Objects/Editor/EditorConf' import TableOfContents from './TableOfContents' import { CustomHeading } from './CustomHeadingExtenstion' +import WebPreview from '@components/Objects/Editor/Extensions/WebPreview/WebPreview' interface Editor { content: string @@ -131,6 +132,10 @@ function Canva(props: Editor) { resizable: true, }), getLinkExtension(), + WebPreview.configure({ + editable: true, + activity: props.activity, + }), TableRow, TableHeader, TableCell, diff --git a/apps/web/components/Objects/Editor/Editor.tsx b/apps/web/components/Objects/Editor/Editor.tsx index b64e8a31..9634e380 100644 --- a/apps/web/components/Objects/Editor/Editor.tsx +++ b/apps/web/components/Objects/Editor/Editor.tsx @@ -34,6 +34,7 @@ import Link from 'next/link' import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { getLinkExtension } from './EditorConf' import { Link as LinkExtension } from '@tiptap/extension-link' +import WebPreview from './Extensions/WebPreview/WebPreview' // Lowlight import { common, createLowlight } from 'lowlight' @@ -164,6 +165,10 @@ function Editor(props: Editor) { TableHeader, TableCell, getLinkExtension(), + WebPreview.configure({ + editable: true, + activity: props.activity, + }), ], content: props.content, immediatelyRender: false, diff --git a/apps/web/components/Objects/Editor/Extensions/WebPreview/WebPreview.ts b/apps/web/components/Objects/Editor/Extensions/WebPreview/WebPreview.ts new file mode 100644 index 00000000..f37923d6 --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/WebPreview/WebPreview.ts @@ -0,0 +1,40 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import { ReactNodeViewRenderer } from '@tiptap/react' +import WebPreviewComponent from './WebPreviewComponent' + +const WebPreview = Node.create({ + name: 'blockWebPreview', + group: 'block', + atom: true, + + addAttributes() { + return { + url: { default: null }, + title: { default: null }, + description: { default: null }, + og_image: { default: null }, + favicon: { default: null }, + og_type: { default: null }, + og_url: { default: null }, + alignment: { default: 'left' }, + buttonLabel: { default: 'Visit Site' }, + showButton: { default: false }, + } + }, + + parseHTML() { + return [ + { tag: 'web-preview' }, + ] + }, + + renderHTML({ HTMLAttributes }) { + return ['web-preview', mergeAttributes(HTMLAttributes), 0] + }, + + addNodeView() { + return ReactNodeViewRenderer(WebPreviewComponent) + }, +}) + +export default WebPreview; \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Extensions/WebPreview/WebPreviewComponent.tsx b/apps/web/components/Objects/Editor/Extensions/WebPreview/WebPreviewComponent.tsx new file mode 100644 index 00000000..55530524 --- /dev/null +++ b/apps/web/components/Objects/Editor/Extensions/WebPreview/WebPreviewComponent.tsx @@ -0,0 +1,301 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { NodeViewWrapper } from '@tiptap/react'; +import { Globe, Edit2, Save, X, AlignLeft, AlignCenter, AlignRight, Trash } from 'lucide-react'; +import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'; +import { getUrlPreview } from '@services/courses/activities'; + +interface WebPreviewProps { + node: any; + updateAttributes: (attrs: any) => void; + extension: any; + deleteNode?: () => void; +} + +const ALIGNMENTS = [ + { value: 'left', label: }, + { value: 'center', label: }, + { value: 'right', label: }, +]; + +const WebPreviewComponent: React.FC = ({ node, updateAttributes, deleteNode }) => { + const [inputUrl, setInputUrl] = useState(node.attrs.url || ''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(!node.attrs.url); + const inputRef = useRef(null); + const editorState = useEditorProvider && useEditorProvider(); + let isEditable = true; + if (editorState) { + isEditable = (editorState as any).isEditable; + } + + const previewData = { + title: node.attrs.title, + description: node.attrs.description, + og_image: node.attrs.og_image, + favicon: node.attrs.favicon, + og_type: node.attrs.og_type, + og_url: node.attrs.og_url, + url: node.attrs.url, + }; + + const alignment = node.attrs.alignment || 'left'; + const hasPreview = !!previewData.title; + + const [buttonLabel, setButtonLabel] = useState(node.attrs.buttonLabel || 'Visit Site'); + const [showButton, setShowButton] = useState(node.attrs.showButton !== false); + + const fetchPreview = async (url: string) => { + setLoading(true); + setError(null); + try { + const res = await getUrlPreview(url); + if (!res) throw new Error('Failed to fetch preview'); + const data = res; + updateAttributes({ ...data, url }); + setEditing(false); + } catch (err: any) { + setError(err.message || 'Error fetching preview'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (node.attrs.url && !hasPreview) { + fetchPreview(node.attrs.url); + } + // eslint-disable-next-line + }, []); + + useEffect(() => { + if (editing && inputRef.current) { + inputRef.current.focus(); + } + }, [editing]); + + useEffect(() => { + setButtonLabel(node.attrs.buttonLabel || 'Visit Site'); + setShowButton(!!node.attrs.showButton); + }, [node.attrs.buttonLabel, node.attrs.showButton]); + + const handleAlignmentChange = (value: string) => { + updateAttributes({ alignment: value }); + }; + + const handleEdit = () => { + setEditing(true); + setInputUrl(node.attrs.url || ''); + }; + + const handleSaveEdit = () => { + if (inputUrl && inputUrl !== node.attrs.url) { + fetchPreview(inputUrl); + } else { + setEditing(false); + } + updateAttributes({ buttonLabel, showButton }); + }; + + const handleCancelEdit = () => { + setEditing(false); + setInputUrl(node.attrs.url || ''); + setError(null); + }; + + const handleDelete = () => { + if (typeof deleteNode === 'function') { + deleteNode(); + } else { + updateAttributes({ url: null, title: null, description: null, og_image: null, favicon: null, og_type: null, og_url: null }); + } + }; + + // Compute alignment class for CardWrapper + let alignClass = 'justify-start'; + if (alignment === 'center') alignClass = 'justify-center'; + else if (alignment === 'right') alignClass = 'justify-end'; + + return ( + +
{/* CardWrapper */} +
{/* PreviewCard */} + {/* Floating edit and delete buttons (only if not editing and isEditable) */} + {isEditable && !editing && ( +
+ + +
+ )} + {/* Only show edit bar when editing */} + {isEditable && editing && ( + <> +
{/* EditBar */} + + setInputUrl(e.target.value)} + disabled={loading} + onKeyDown={e => { if (e.key === 'Enter') handleSaveEdit(); }} + className="flex-1 border border-gray-200 rounded-md px-2.5 py-1.5 text-sm font-sans focus:outline-none focus:border-gray-400" + /> + + +
+ {/* Button toggle and label input */} +
+ + {showButton && ( + { + setButtonLabel(e.target.value); + updateAttributes({ buttonLabel: e.target.value }); + }} + placeholder="Button label" + className="border border-gray-200 rounded-md px-2 py-1 text-sm font-sans focus:outline-none focus:border-gray-400" + style={{ minWidth: 100 }} + /> + )} +
+ + )} + {error &&
{error}
} + {/* Only show preview card when not editing */} + {hasPreview && !editing && ( + <> + + {previewData.og_image && ( +
+ preview +
+ )} +
+ +
+ {previewData.favicon && ( + favicon + )} + {previewData.url} +
+ {showButton && previewData.url && ( + + {buttonLabel || 'Visit Site'} + + )} + + )} + {isEditable && !editing && ( +
{/* AlignmentBar */} + Align: + {ALIGNMENTS.map(opt => ( + + ))} +
+ )} +
+
+
+ ); +}; + +export default WebPreviewComponent; \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx index 027581e7..0c669d3e 100644 --- a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx +++ b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx @@ -33,6 +33,7 @@ import { Video, List, ListOrdered, + Globe, } from 'lucide-react' import { SiYoutube } from '@icons-pack/react-simple-icons' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' @@ -418,6 +419,17 @@ export const ToolbarButtons = ({ editor, props }: any) => { + + + editor.chain().focus().insertContent({ + type: 'blockWebPreview', + }).run() + } + > + + + ) } diff --git a/apps/web/services/courses/activities.ts b/apps/web/services/courses/activities.ts index d8bdd017..704ec79d 100644 --- a/apps/web/services/courses/activities.ts +++ b/apps/web/services/courses/activities.ts @@ -160,3 +160,12 @@ export async function updateActivity( const res = await getResponseMetadata(result) return res } + +export async function getUrlPreview(url: string) { + const result = await fetch( + `${getAPIUrl()}utils/link-preview?url=${url}`, + RequestBodyWithAuthHeader('GET', null, null, undefined) + ) + const res = await result.json() + return res +}