feat: add TikTok embed support
This commit is contained in:
245
src/app/components/url-preview/TikTokEmbed.tsx
Normal file
245
src/app/components/url-preview/TikTokEmbed.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, { MouseEvent, useEffect, useState } from 'react';
|
||||
import { Box, Spinner, Text, as } from 'folds';
|
||||
import * as css from './TikTokEmbed.css';
|
||||
import { openExternalUrl } from '../../utils/tauri';
|
||||
|
||||
/**
|
||||
* Regex patterns to match TikTok URLs
|
||||
*/
|
||||
const TIKTOK_URL_PATTERNS = [
|
||||
// Standard tiktok.com/@username/video/VIDEO_ID
|
||||
/(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@[\w.-]+\/video\/(\d+)/i,
|
||||
// Short vm.tiktok.com/VIDEO_CODE
|
||||
/(?:https?:\/\/)?(?:www\.)?vm\.tiktok\.com\/([\w]+)/i,
|
||||
// Short vt.tiktok.com/VIDEO_CODE
|
||||
/(?:https?:\/\/)?(?:www\.)?vt\.tiktok\.com\/([\w]+)/i,
|
||||
// Mobile m.tiktok.com
|
||||
/(?:https?:\/\/)?(?:www\.)?m\.tiktok\.com\/v\/(\d+)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* TikTok oEmbed API base
|
||||
*/
|
||||
const TIKTOK_OEMBED_API = 'https://www.tiktok.com/oembed';
|
||||
|
||||
/**
|
||||
* Click handler for external links - uses Tauri shell on desktop/mobile
|
||||
*/
|
||||
const handleExternalLinkClick = (e: MouseEvent<HTMLAnchorElement>) => {
|
||||
const { href } = e.currentTarget;
|
||||
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
|
||||
e.preventDefault();
|
||||
openExternalUrl(href);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a URL is a TikTok URL
|
||||
* @param url The URL to check
|
||||
* @returns True if the URL is a TikTok URL
|
||||
*/
|
||||
export function isTikTokUrl(url: string): boolean {
|
||||
return TIKTOK_URL_PATTERNS.some((pattern) => pattern.test(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches TikTok video metadata using oEmbed API
|
||||
* @param url The TikTok video URL
|
||||
* @returns Video metadata including title, author, and thumbnail
|
||||
*/
|
||||
async function fetchTikTokOEmbed(url: string): Promise<{
|
||||
title: string;
|
||||
authorName: string;
|
||||
authorUrl: string;
|
||||
thumbnailUrl: string;
|
||||
embedHtml: string;
|
||||
} | null> {
|
||||
try {
|
||||
const oembedUrl = `${TIKTOK_OEMBED_API}?url=${encodeURIComponent(url)}`;
|
||||
const response = await fetch(oembedUrl);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
title: data.title || 'TikTok Video',
|
||||
authorName: data.author_name || 'Unknown',
|
||||
authorUrl: data.author_url || url,
|
||||
thumbnailUrl: data.thumbnail_url || '',
|
||||
embedHtml: data.html || '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type TikTokEmbedState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'loaded'; title: string; authorName: string; authorUrl: string; thumbnailUrl: string; embedHtml: string }
|
||||
| { status: 'error' };
|
||||
|
||||
type TikTokEmbedProps = {
|
||||
/** The original TikTok URL */
|
||||
url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* TikTok embed component that shows video preview with metadata
|
||||
*/
|
||||
export const TikTokEmbed = as<'div', TikTokEmbedProps>(({ url, ...props }, ref) => {
|
||||
const [state, setState] = useState<TikTokEmbedState>({ status: 'loading' });
|
||||
const [showEmbed, setShowEmbed] = useState(false);
|
||||
|
||||
// Fetch video info on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetchTikTokOEmbed(url).then((info) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (info) {
|
||||
setState({
|
||||
status: 'loaded',
|
||||
title: info.title,
|
||||
authorName: info.authorName,
|
||||
authorUrl: info.authorUrl,
|
||||
thumbnailUrl: info.thumbnailUrl,
|
||||
embedHtml: info.embedHtml,
|
||||
});
|
||||
} else {
|
||||
setState({ status: 'error' });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
// Loading state
|
||||
if (state.status === 'loading') {
|
||||
return (
|
||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||
<div className={css.TikTokEmbedHeader}>
|
||||
<Text
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="T200"
|
||||
className={css.TikTokEmbedLink}
|
||||
onClick={handleExternalLinkClick}
|
||||
truncate
|
||||
>
|
||||
TikTok - Loading...
|
||||
</Text>
|
||||
</div>
|
||||
<Box
|
||||
className={css.TikTokThumbnailContainer}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ background: '#000', display: 'flex' }}
|
||||
>
|
||||
<Spinner variant="Secondary" size="600" />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state - show basic link
|
||||
if (state.status === 'error') {
|
||||
return (
|
||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||
<div className={css.TikTokEmbedHeader}>
|
||||
<Text
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="T200"
|
||||
className={css.TikTokEmbedLink}
|
||||
onClick={handleExternalLinkClick}
|
||||
truncate
|
||||
>
|
||||
TikTok Video
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Loaded state - show thumbnail or embed
|
||||
if (!showEmbed) {
|
||||
return (
|
||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||
<div className={css.TikTokEmbedHeader}>
|
||||
<div className={css.TikTokEmbedInfo}>
|
||||
<Text
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="T300"
|
||||
className={css.TikTokEmbedTitle}
|
||||
onClick={handleExternalLinkClick}
|
||||
truncate
|
||||
>
|
||||
{state.title}
|
||||
</Text>
|
||||
<Text
|
||||
as="a"
|
||||
href={state.authorUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="T200"
|
||||
className={css.TikTokEmbedAuthor}
|
||||
onClick={handleExternalLinkClick}
|
||||
truncate
|
||||
>
|
||||
{state.authorName}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={css.TikTokThumbnailButton}
|
||||
onClick={() => setShowEmbed(true)}
|
||||
aria-label="Load TikTok video"
|
||||
>
|
||||
{state.thumbnailUrl && (
|
||||
<img
|
||||
src={state.thumbnailUrl}
|
||||
alt={state.title}
|
||||
className={css.TikTokThumbnail}
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Show embed (using dangerouslySetInnerHTML for TikTok's oEmbed HTML)
|
||||
return (
|
||||
<Box shrink="No" className={css.TikTokEmbed} direction="Column" {...props} ref={ref}>
|
||||
<div className={css.TikTokEmbedHeader}>
|
||||
<Text
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="T200"
|
||||
className={css.TikTokEmbedLink}
|
||||
onClick={handleExternalLinkClick}
|
||||
truncate
|
||||
>
|
||||
{state.title}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={css.TikTokEmbedContainer}
|
||||
dangerouslySetInnerHTML={{ __html: state.embedHtml }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user