mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: add WebPreviews blocks
This commit is contained in:
parent
b1c4ddd0b7
commit
8d3ede1486
11 changed files with 482 additions and 0 deletions
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
12
apps/api/src/routers/utils.py
Normal file
12
apps/api/src/routers/utils.py
Normal 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)}")
|
||||||
70
apps/api/src/services/utils/link_preview.py
Normal file
70
apps/api/src/services/utils/link_preview.py
Normal 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
24
apps/api/uv.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue