feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s

- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
This commit is contained in:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

View File

@@ -0,0 +1,265 @@
import { useEffect, useRef, useState } from 'react';
import { ArrowSquareOut, Play } from '@phosphor-icons/react';
import { useAction } from 'convex/react';
import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform';
import styles from './LinkEmbed.module.css';
interface UrlPreview {
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
}
const VIDEO_HOSTS = [
'youtube.com',
'youtu.be',
'www.youtube.com',
'vimeo.com',
'www.vimeo.com',
'twitch.tv',
'www.twitch.tv',
'dailymotion.com',
'www.dailymotion.com',
];
const DIRECT_VIDEO_EXTS = ['.mp4', '.webm', '.ogg', '.mov'];
const DIRECT_IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'];
function isDirectMedia(url: string): 'video' | 'image' | null {
try {
const pathname = new URL(url).pathname.toLowerCase();
if (DIRECT_VIDEO_EXTS.some((ext) => pathname.endsWith(ext))) return 'video';
if (DIRECT_IMAGE_EXTS.some((ext) => pathname.endsWith(ext))) return 'image';
} catch {}
return null;
}
function isVideoUrl(url: string): boolean {
try {
const hostname = new URL(url).hostname;
return VIDEO_HOSTS.some((h) => hostname === h || hostname.endsWith('.' + h));
} catch {
return false;
}
}
// Module-scope cache so we don't refetch the same URL every re-render.
// `null` means "we tried and there's no preview" — we cache that too to
// avoid hammering the fetcher for URLs that will never resolve.
const previewCache = new Map<string, UrlPreview | null>();
function normaliseMetadata(raw: any): UrlPreview | null {
if (!raw || typeof raw !== 'object') return null;
// Accept a few possible shapes: the Electron preload IPC returns
// `{ title, description, image, siteName, url }`; matrix-js-sdk style
// returns `{ 'og:title', 'og:description', 'og:image', 'og:site_name' }`.
const title =
raw.title ?? raw['og:title'] ?? raw.ogTitle ?? undefined;
const description =
raw.description ?? raw['og:description'] ?? raw.ogDescription ?? undefined;
const imageUrl =
raw.image ?? raw.imageUrl ?? raw['og:image'] ?? raw.ogImage ?? undefined;
const siteName =
raw.siteName ?? raw['og:site_name'] ?? raw.ogSiteName ?? undefined;
if (!title && !description && !imageUrl) return null;
return { title, description, imageUrl, siteName };
}
function useUrlPreview(url: string): UrlPreview | null {
const platform = usePlatform();
const fetchPreviewAction = useAction(api.links.fetchPreview);
const [preview, setPreview] = useState<UrlPreview | null>(
previewCache.get(url) ?? null,
);
useEffect(() => {
if (previewCache.has(url)) {
setPreview(previewCache.get(url) ?? null);
return;
}
let cancelled = false;
(async () => {
try {
// Prefer the platform-native fetcher when available (Electron
// ships one via IPC — no CORS, no round-trip through the
// backend). On web, `fetchMetadata` returns null due to CORS,
// so we fall through to the Convex Node action which performs
// the fetch server-side.
let result: UrlPreview | null = null;
const fetcher = platform?.links?.fetchMetadata;
if (typeof fetcher === 'function') {
try {
const raw = await fetcher(url);
result = normaliseMetadata(raw);
} catch {
result = null;
}
}
if (!result) {
try {
const raw = await fetchPreviewAction({ url });
if (!cancelled) result = normaliseMetadata(raw);
} catch {
result = null;
}
}
if (cancelled) return;
previewCache.set(url, result);
setPreview(result);
} catch {
if (!cancelled) previewCache.set(url, null);
}
})();
return () => {
cancelled = true;
};
}, [url, platform, fetchPreviewAction]);
return preview;
}
function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image' }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
if (type === 'video') {
const handlePlay = () => {
if (!videoRef.current) return;
videoRef.current.controls = true;
void videoRef.current.play();
setPlaying(true);
};
return (
<div className={styles.embed}>
<div className={styles.directVideoWrapper}>
<video
ref={videoRef}
className={styles.directVideo}
src={url}
preload="metadata"
onPause={() => {
if (videoRef.current && videoRef.current.ended) {
videoRef.current.controls = false;
setPlaying(false);
}
}}
onEnded={() => {
if (videoRef.current) {
videoRef.current.controls = false;
setPlaying(false);
}
}}
/>
{!playing && (
<button
type="button"
className={styles.directPlayOverlay}
onClick={handlePlay}
>
<Play size={36} weight="fill" />
</button>
)}
</div>
</div>
);
}
return (
<div className={styles.embed}>
<a href={url} target="_blank" rel="noopener noreferrer">
<img
className={styles.directImage}
src={url}
alt=""
loading="lazy"
/>
</a>
</div>
);
}
interface LinkEmbedProps {
url: string;
}
export function LinkEmbed({ url }: LinkEmbedProps) {
const directType = isDirectMedia(url);
if (directType) {
return <DirectMediaEmbed url={url} type={directType} />;
}
const preview = useUrlPreview(url);
// If the platform has no metadata fetcher (or it returned null / threw),
// degrade gracefully to nothing — the raw link is already rendered in
// the message body by MessageContent.
if (!preview) return null;
const isVideo = isVideoUrl(url);
const hasImage = !!preview.imageUrl;
return (
<div className={styles.embed}>
<div className={styles.grid}>
<div className={styles.embedContent}>
{preview.siteName && (
<div className={styles.provider}>{preview.siteName}</div>
)}
{preview.title && (
<a
className={styles.title}
href={url}
target="_blank"
rel="noopener noreferrer"
>
{preview.title}
</a>
)}
{preview.description && (
<div className={styles.description}>{preview.description}</div>
)}
{hasImage && (
<div className={styles.mediaContainer}>
<img
className={styles.mediaImage}
src={preview.imageUrl}
alt={preview.title || ''}
loading="lazy"
/>
{isVideo && (
<a
className={styles.mediaOverlay}
href={url}
target="_blank"
rel="noopener noreferrer"
>
<div className={styles.mediaControls}>
<button type="button" className={styles.playButton}>
<Play size={28} weight="fill" />
</button>
<button type="button" className={styles.openButton}>
<ArrowSquareOut size={22} />
</button>
</div>
</a>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default LinkEmbed;