Skip to content

Commit 76ccf4f

Browse files
committed
feat: Introduce BoardCard component for displaying draggable board items with content previews and OS integration.
1 parent 3769fb5 commit 76ccf4f

2 files changed

Lines changed: 85 additions & 7 deletions

File tree

src/apps/Board/BoardCard.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { X, GripVertical, FileText, File, Folder, AppWindow, ImageIcon, FileCode
44
import ReactMarkdown from 'react-markdown';
55
import { cn } from '../../lib/utils';
66
import { BoardCard as BoardCardType } from './types';
7+
import { fetchMetadata, LinkMetadata } from './utils';
78
import { APPS_CONFIG } from '../../lib/apps';
89
import { useFileSystem } from '../../hooks/useFileSystem';
910
import { useOS } from '../../hooks/useOS';
@@ -18,10 +19,17 @@ interface CardProps {
1819
export const BoardCard: React.FC<CardProps> = ({ card, onUpdate, onDelete }) => {
1920
const [isEditing, setIsEditing] = useState(false);
2021
const [previewContent, setPreviewContent] = useState<string | null>(null);
22+
const [metadata, setMetadata] = useState<LinkMetadata | null>(null);
2123
const [previewType, setPreviewType] = useState<'text' | 'image' | 'video' | 'code' | 'markdown' | 'html' | 'none'>('none');
2224
const { readFile } = useFileSystem();
2325
const { openApp, sendAppAction, appWindows } = useOS();
2426

27+
useEffect(() => {
28+
if (card.type === 'link' && card.metadata?.url) {
29+
fetchMetadata(card.metadata.url).then(setMetadata);
30+
}
31+
}, [card.type, card.metadata?.url]);
32+
2533
const handleOpenFile = () => {
2634
const path = card.metadata?.path || card.metadata?.url;
2735
if (!path) return;
@@ -127,13 +135,37 @@ export const BoardCard: React.FC<CardProps> = ({ card, onUpdate, onDelete }) =>
127135
{(card.type === 'link' || !card.metadata?.isDirectory) && (
128136
<div className="flex-1 bg-zinc-50/50 rounded-xl border border-zinc-100/50 overflow-hidden relative group/preview min-h-[160px]">
129137
{card.type === 'link' ? (
130-
<div className="w-full h-full relative group-hover/preview:opacity-95 transition-opacity">
131-
<iframe
132-
src={card.metadata?.url}
133-
className="w-full h-full border-0 pointer-events-none scale-[0.5] origin-top-left"
134-
style={{ width: '200%', height: '200%' }}
135-
title="Link Preview"
136-
/>
138+
<div className="w-full h-full relative group-hover/preview:opacity-95 transition-all flex flex-col">
139+
{metadata?.image ? (
140+
<div className="w-full h-24 overflow-hidden bg-zinc-100">
141+
<img
142+
src={metadata.image}
143+
className="w-full h-full object-cover"
144+
alt="link preview"
145+
onError={(e) => e.currentTarget.style.display = 'none'}
146+
/>
147+
</div>
148+
) : (
149+
<div className="w-full h-24 bg-sky-50 flex items-center justify-center">
150+
<Globe size={24} className="text-sky-200" />
151+
</div>
152+
)}
153+
<div className="p-3 flex flex-col gap-1">
154+
<span className="text-[11px] font-bold text-zinc-800 line-clamp-1">
155+
{metadata?.title || card.content}
156+
</span>
157+
<p className="text-[10px] text-zinc-500 line-clamp-2 leading-tight">
158+
{metadata?.description || "No description available"}
159+
</p>
160+
<div className="flex items-center gap-1 mt-1">
161+
<div className="w-3 h-3 rounded-full bg-sky-100 flex items-center justify-center">
162+
<Globe size={8} className="text-sky-500" />
163+
</div>
164+
<span className="text-[8px] text-zinc-400 font-bold uppercase tracking-wider">
165+
{metadata?.siteName || metadata?.url.split('/')[2]?.replace('www.', '') || "Website"}
166+
</span>
167+
</div>
168+
</div>
137169
<div className="absolute inset-0 z-10" />
138170
</div>
139171
) : (card.type as string) === 'widget' ? (

src/apps/Board/utils.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export interface LinkMetadata {
2+
title?: string;
3+
description?: string;
4+
image?: string;
5+
type?: string;
6+
url: string;
7+
siteName?: string;
8+
}
9+
10+
export const fetchMetadata = async (url: string): Promise<LinkMetadata | null> => {
11+
try {
12+
// @ts-ignore - proxyRequest is exposed in preload.ts
13+
const html = await window.electron.proxyRequest(url);
14+
if (!html || typeof html !== 'string') return null;
15+
16+
const doc = new DOMParser().parseFromString(html, 'text/html');
17+
const getMeta = (name: string) =>
18+
doc.querySelector(`meta[property="${name}"]`)?.getAttribute('content') ||
19+
doc.querySelector(`meta[name="${name}"]`)?.getAttribute('content');
20+
21+
const metadata: LinkMetadata = {
22+
url,
23+
title: getMeta('og:title') || doc.title || '',
24+
description: getMeta('og:description') || getMeta('description') || '',
25+
image: getMeta('og:image') || '',
26+
type: getMeta('og:type') || 'website',
27+
siteName: getMeta('og:site_name') || ''
28+
};
29+
30+
// YouTube specific handling if OG is missing or needs better images
31+
if (url.includes('youtube.com') || url.includes('youtu.be')) {
32+
const videoId = url.includes('watch?v=')
33+
? url.split('v=')[1]?.split('&')[0]
34+
: url.includes('youtu.be/') ? url.split('youtu.be/')[1]?.split('?')[0] : null;
35+
36+
if (videoId && !metadata.image) {
37+
metadata.image = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
38+
}
39+
}
40+
41+
return metadata;
42+
} catch (e) {
43+
console.error('Metadata fetch failed', e);
44+
return null;
45+
}
46+
};

0 commit comments

Comments
 (0)