opengraph stuff

This commit is contained in:
2026-02-24 21:47:36 +00:00
parent 05f0d0e8f7
commit e36908cb0b
61 changed files with 5870 additions and 178 deletions
@@ -0,0 +1,72 @@
import { useQuery } from '@tanstack/react-query';
import { Mail } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
import type { EmailSummary } from 'types';
const formatDate = (iso: string) => {
const date = new Date(iso);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const isThisYear = date.getFullYear() === now.getFullYear();
if (isThisYear) return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
return date.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
};
export const EmailList = () => {
const client = useClient();
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const { data, isLoading } = useQuery({
queryKey: ['email-messages'],
queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages'),
});
const messages = data?.messages ?? [];
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm opacity-50">
Loading emails...
</div>
);
}
if (messages.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Mail className="h-8 w-8" />
No emails found
</div>
);
}
return (
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Mail className="h-4 w-4 opacity-60" />
<span className="text-sm font-medium">Inbox</span>
<span className="text-xs opacity-50">{data?.total ?? 0}</span>
</div>
<div className="flex flex-col">
{messages.map((msg: EmailSummary) => (
<button
key={msg.id}
onClick={() => setSelectedId(msg.id)}
className={`flex flex-col gap-0.5 border-b px-3 py-2.5 text-left transition-colors cursor-pointer ${
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{msg.from}</span>
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
</div>
<span className="truncate text-sm">{msg.subject}</span>
<span className="truncate text-xs opacity-50">{msg.snippet}</span>
</button>
))}
</div>
</div>
);
};
@@ -0,0 +1,102 @@
import { useEffect, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Mail } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal';
import type { EmailMessage } from 'types';
const HtmlBody = ({ html }: { html: string }) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) return;
const doc = iframe.contentDocument;
if (!doc) return;
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 14px; margin: 0; padding: 16px; color: #e0e0e0; background: transparent; }
a { color: #60a5fa; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>${html}</body>
</html>
`);
doc.close();
}, [html]);
return <iframe ref={iframeRef} className="h-full w-full border-0" sandbox="allow-same-origin" title="Email body" />;
};
export const EmailReader = () => {
const client = useClient();
const [selectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const { data: message, isLoading } = useQuery({
queryKey: ['email-message', selectedId],
queryFn: () => client.get<EmailMessage>(`/email/messages/${selectedId}`),
enabled: !!selectedId,
});
if (!selectedId) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Mail className="h-10 w-10" />
Select an email to read
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-sm opacity-50">
Loading...
</div>
);
}
if (!message) return null;
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex flex-col gap-1 border-b px-4 py-3">
<h2 className="text-lg font-semibold">{message.subject}</h2>
<div className="flex flex-col gap-0.5 text-sm opacity-70">
<div>
<span className="font-medium">From:</span> {message.from}
</div>
<div>
<span className="font-medium">To:</span> {message.to}
</div>
{message.cc && (
<div>
<span className="font-medium">Cc:</span> {message.cc}
</div>
)}
<div className="text-xs opacity-60">{new Date(message.date).toLocaleString()}</div>
</div>
{message.attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{message.attachments.map((att: { filename: string; size: number; contentType: string }, i: number) => (
<span key={i} className="rounded bg-accent px-2 py-0.5 text-xs">
{att.filename} ({Math.round(att.size / 1024)}KB)
</span>
))}
</div>
)}
</div>
<div className="flex-1 overflow-auto">
{message.html ? (
<HtmlBody html={message.html} />
) : (
<pre className="whitespace-pre-wrap p-4 text-sm">{message.text}</pre>
)}
</div>
</div>
);
};
@@ -0,0 +1,37 @@
import { useMemo, useCallback } from 'react';
import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev';
import { useIsMobile } from 'hooks/useIsMobile';
import { useGlobal } from 'hooks/useGlobal';
import { defaultLayout } from './defaultLayout';
import { EmailList } from './EmailList';
import { EmailReader } from './EmailReader';
export const EmailScreen = () => {
const isMobile = useIsMobile();
const [selectedId, setSelectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const components: PanelComponents = useMemo(
() => ({
'email-list': EmailList,
'email-reader': EmailReader,
}),
[],
);
const mobilePanelId = isMobile && selectedId ? 'email-reader' : undefined;
const onMobileBack = useCallback(() => setSelectedId(null), [setSelectedId]);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout
layout={defaultLayout}
onLayoutChange={() => {}}
components={components}
isMobile={isMobile}
mobilePanelId={mobilePanelId}
onMobileBack={mobilePanelId ? onMobileBack : null}
/>
</div>
);
};
@@ -0,0 +1,11 @@
import type { LayoutNode } from 'officerdev';
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'email-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'email-list', appType: null }, size: 30 },
{ node: { type: 'panel', id: 'email-reader', appType: null }, size: 70 },
],
};
@@ -0,0 +1 @@
export { EmailScreen } from './EmailScreen';