feat: add WebPreviews blocks

This commit is contained in:
swve 2025-06-05 11:08:05 +02:00
parent b1c4ddd0b7
commit 8d3ede1486
11 changed files with 482 additions and 0 deletions

View file

@ -38,6 +38,7 @@ dependencies = [
"stripe>=11.1.1", "stripe>=11.1.1",
"python-jose>=3.3.0", "python-jose>=3.3.0",
"logfire[sqlalchemy]>=3.8.0", "logfire[sqlalchemy]>=3.8.0",
"beautifulsoup4>=4.13.4",
] ]
[tool.ruff] [tool.ruff]

View file

@ -10,6 +10,7 @@ from src.routers.ee import cloud_internal, payments
from src.routers.install import install from src.routers.install import install
from src.services.dev.dev import isDevModeEnabledOrRaise from src.services.dev.dev import isDevModeEnabledOrRaise
from src.services.install.install import isInstallModeEnabled from src.services.install.install import isInstallModeEnabled
from src.routers.utils import router as utils_router
v1_router = APIRouter(prefix="/api/v1") v1_router = APIRouter(prefix="/api/v1")
@ -61,3 +62,5 @@ v1_router.include_router(
tags=["install"], tags=["install"],
dependencies=[Depends(isInstallModeEnabled)], dependencies=[Depends(isInstallModeEnabled)],
) )
v1_router.include_router(utils_router, prefix="/utils", tags=["utils"])

View file

@ -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)}")

View file

@ -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,
}

24
apps/api/uv.lock generated
View file

@ -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 }, { 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]] [[package]]
name = "boto3" name = "boto3"
version = "1.36.22" version = "1.36.22"
@ -999,6 +1012,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "alembic-postgresql-enum" }, { name = "alembic-postgresql-enum" },
{ name = "beautifulsoup4" },
{ name = "boto3" }, { name = "boto3" },
{ name = "botocore" }, { name = "botocore" },
{ name = "chromadb" }, { name = "chromadb" },
@ -1037,6 +1051,7 @@ dependencies = [
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.13.2" }, { name = "alembic", specifier = ">=1.13.2" },
{ name = "alembic-postgresql-enum", specifier = ">=1.2.0" }, { name = "alembic-postgresql-enum", specifier = ">=1.2.0" },
{ name = "beautifulsoup4", specifier = ">=4.13.4" },
{ name = "boto3", specifier = ">=1.34.79" }, { name = "boto3", specifier = ">=1.34.79" },
{ name = "botocore", specifier = ">=1.34.93" }, { name = "botocore", specifier = ">=1.34.93" },
{ name = "chromadb", specifier = "==0.5.16" }, { 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 }, { 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]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.38" version = "2.0.38"

View file

@ -35,6 +35,7 @@ import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock'
import { getLinkExtension } from '@components/Objects/Editor/EditorConf' import { getLinkExtension } from '@components/Objects/Editor/EditorConf'
import TableOfContents from './TableOfContents' import TableOfContents from './TableOfContents'
import { CustomHeading } from './CustomHeadingExtenstion' import { CustomHeading } from './CustomHeadingExtenstion'
import WebPreview from '@components/Objects/Editor/Extensions/WebPreview/WebPreview'
interface Editor { interface Editor {
content: string content: string
@ -131,6 +132,10 @@ function Canva(props: Editor) {
resizable: true, resizable: true,
}), }),
getLinkExtension(), getLinkExtension(),
WebPreview.configure({
editable: true,
activity: props.activity,
}),
TableRow, TableRow,
TableHeader, TableHeader,
TableCell, TableCell,

View file

@ -34,6 +34,7 @@ import Link from 'next/link'
import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { getLinkExtension } from './EditorConf' import { getLinkExtension } from './EditorConf'
import { Link as LinkExtension } from '@tiptap/extension-link' import { Link as LinkExtension } from '@tiptap/extension-link'
import WebPreview from './Extensions/WebPreview/WebPreview'
// Lowlight // Lowlight
import { common, createLowlight } from 'lowlight' import { common, createLowlight } from 'lowlight'
@ -164,6 +165,10 @@ function Editor(props: Editor) {
TableHeader, TableHeader,
TableCell, TableCell,
getLinkExtension(), getLinkExtension(),
WebPreview.configure({
editable: true,
activity: props.activity,
}),
], ],
content: props.content, content: props.content,
immediatelyRender: false, immediatelyRender: false,

View file

@ -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;

View file

@ -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: <AlignLeft size={16} /> },
{ value: 'center', label: <AlignCenter size={16} /> },
{ value: 'right', label: <AlignRight size={16} /> },
];
const WebPreviewComponent: React.FC<WebPreviewProps> = ({ node, updateAttributes, deleteNode }) => {
const [inputUrl, setInputUrl] = useState(node.attrs.url || '');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(!node.attrs.url);
const inputRef = useRef<HTMLInputElement>(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 (
<NodeViewWrapper className="web-preview-block relative">
<div className={`flex w-full ${alignClass}`}> {/* CardWrapper */}
<div className="bg-white nice-shadow rounded-xl max-w-[420px] min-w-[260px] my-2 px-6 pt-6 pb-4 relative "> {/* PreviewCard */}
{/* Floating edit and delete buttons (only if not editing and isEditable) */}
{isEditable && !editing && (
<div className="flex flex-col gap-2 absolute -top-3 -right-3 z-20">
<button
className="flex items-center justify-center bg-yellow-50 text-yellow-700 border border-yellow-200 shadow-md rounded-md p-1.5 hover:bg-yellow-100"
onClick={handleEdit}
title="Edit URL"
type="button"
>
<Edit2 size={16} />
</button>
<button
className="flex items-center justify-center bg-red-50 text-red-700 border border-red-200 shadow-md rounded-md p-1.5 hover:bg-red-100"
onClick={handleDelete}
title="Delete Card"
type="button"
>
<Trash size={16} />
</button>
</div>
)}
{/* Only show edit bar when editing */}
{isEditable && editing && (
<>
<div className="flex items-center gap-2 mb-2"> {/* EditBar */}
<Globe size={18} style={{ opacity: 0.7, marginRight: 4 }} />
<input
ref={inputRef}
type="text"
placeholder="Enter website URL..."
value={inputUrl}
onChange={e => 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
onClick={handleSaveEdit}
disabled={loading || !inputUrl}
title="Save"
type="button"
className="flex items-center justify-center bg-gray-100 border-none rounded-md p-1 cursor-pointer text-gray-700 transition-colors duration-150 hover:bg-gray-200 aria-pressed:bg-blue-600 aria-pressed:text-white disabled:opacity-50"
aria-pressed={false}
>
{loading ? <Save size={16} /> : <Save size={16} />}
</button>
<button
onClick={handleCancelEdit}
title="Cancel"
type="button"
className="flex items-center justify-center bg-gray-100 border-none rounded-md p-1 cursor-pointer text-gray-700 transition-colors duration-150 hover:bg-gray-200"
>
<X size={16} />
</button>
</div>
{/* Button toggle and label input */}
<div className="flex items-center gap-2 mb-2">
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={showButton}
onChange={e => {
setShowButton(e.target.checked);
updateAttributes({ showButton: e.target.checked });
}}
className="accent-blue-600"
/>
Show button
</label>
{showButton && (
<input
type="text"
value={buttonLabel}
onChange={e => {
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 }}
/>
)}
</div>
</>
)}
{error && <div className="text-red-600 text-xs mt-2">{error}</div>}
{/* Only show preview card when not editing */}
{hasPreview && !editing && (
<>
<a
href={previewData.url}
target="_blank"
rel="noopener noreferrer"
className="no-underline hover:no-underline focus:no-underline active:no-underline"
style={{ textDecoration: 'none', borderBottom: 'none' }}
>
{previewData.og_image && (
<div className="-mt-6 -mx-6 mb-0 rounded-t-xl overflow-hidden">
<img
src={previewData.og_image}
alt="preview"
className="w-full h-40 object-cover block"
/>
</div>
)}
<div className="pt-4 pb-2">
<a
href={previewData.url}
target="_blank"
rel="noopener noreferrer"
className="no-underline hover:no-underline focus:no-underline active:no-underline"
style={{ textDecoration: 'none', borderBottom: 'none' }}
>
<span
className="font-semibold text-lg text-[#232323] mb-1.5 leading-tight no-underline hover:no-underline focus:no-underline active:no-underline"
style={{ textDecoration: 'none', borderBottom: 'none' }}
>
{previewData.title}
</span>
<span
className="block text-gray-700 text-sm mb-3 leading-snug no-underline hover:no-underline focus:no-underline active:no-underline"
style={{ textDecoration: 'none', borderBottom: 'none' }}
>
{previewData.description}
</span>
</a>
</div>
</a>
<div className="flex items-center mt-0 pt-2 border-t border-gray-100">
{previewData.favicon && (
<img
src={previewData.favicon}
alt="favicon"
className="w-[18px] h-[18px] mr-2 rounded bg-gray-100"
/>
)}
<span className="text-gray-500 text-xs truncate">{previewData.url}</span>
</div>
{showButton && previewData.url && (
<a
href={previewData.url}
target="_blank"
rel="noopener noreferrer"
className="block w-full mt-4 rounded-xl bg-white nice-shadow text-[16px] font-semibold text-purple-600 py-2.5 px-4 text-center no-underline hover:bg-gray-50 hover:shadow-lg transition-all [&:not(:hover)]:text-black [&:hover]:text-black"
style={{ textDecoration: 'none', color: 'black' }}
>
{buttonLabel || 'Visit Site'}
</a>
)}
</>
)}
{isEditable && !editing && (
<div className="flex items-center gap-1 mt-2"> {/* AlignmentBar */}
<span className="text-xs text-gray-500 mr-1">Align:</span>
{ALIGNMENTS.map(opt => (
<button
key={opt.value}
aria-pressed={alignment === opt.value}
onClick={() => handleAlignmentChange(opt.value)}
title={`Align ${opt.value}`}
type="button"
className={`flex items-center justify-center border transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-blue-300 p-1.5 rounded-full text-gray-600
${alignment === opt.value
? 'bg-gray-600 text-white border-gray-600 hover:bg-gray-700'
: 'bg-white border-gray-200 hover:bg-gray-100'}
`}
>
{opt.label}
</button>
))}
</div>
)}
</div>
</div>
</NodeViewWrapper>
);
};
export default WebPreviewComponent;

View file

@ -33,6 +33,7 @@ import {
Video, Video,
List, List,
ListOrdered, ListOrdered,
Globe,
} from 'lucide-react' } from 'lucide-react'
import { SiYoutube } from '@icons-pack/react-simple-icons' import { SiYoutube } from '@icons-pack/react-simple-icons'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
@ -418,6 +419,17 @@ export const ToolbarButtons = ({ editor, props }: any) => {
<User size={15} /> <User size={15} />
</ToolBtn> </ToolBtn>
</ToolTip> </ToolTip>
<ToolTip content={'Web Preview'}>
<ToolBtn
onClick={() =>
editor.chain().focus().insertContent({
type: 'blockWebPreview',
}).run()
}
>
<Globe size={15} />
</ToolBtn>
</ToolTip>
</ToolButtonsWrapper> </ToolButtonsWrapper>
) )
} }

View file

@ -160,3 +160,12 @@ export async function updateActivity(
const res = await getResponseMetadata(result) const res = await getResponseMetadata(result)
return res 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
}