Files
cinny/src/app/components/url-preview/TikTokEmbed.tsx

246 lines
6.7 KiB
TypeScript

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>
);
});