opengraph stuff
This commit is contained in:
@@ -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';
|
||||
@@ -110,11 +110,12 @@ export const Dock = ({ items, className }: DockProps) => {
|
||||
};
|
||||
|
||||
|
||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor } from 'lucide-react';
|
||||
import { Home, MessageCircle, FileText, FolderOpen, Code, LayoutGrid, Bot, ScrollText, FolderKanban, Monitor, Mail } from 'lucide-react';
|
||||
|
||||
export const ALL_DOCK_ITEMS: DockItem[] = [
|
||||
{ label: 'Home', to: '/', icon: Home, color: '#f59e0b' },
|
||||
{ label: 'Files', to: '/files', icon: FolderOpen, color: '#fbbf24' },
|
||||
{ label: 'Email', to: '/email', icon: Mail, color: '#ef4444' },
|
||||
{ label: 'Chat', to: '/chat', icon: MessageCircle, color: '#60a5fa' },
|
||||
{ label: 'Editor', to: '/code-editor', icon: Code, color: '#a78bfa' },
|
||||
{ label: 'Plans', to: '/plans', icon: FileText, color: '#f472b6' },
|
||||
|
||||
+81
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { RefreshCw, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useJobs } from 'hooks/useJobs';
|
||||
|
||||
type GoogleStatus = {
|
||||
connected: boolean;
|
||||
@@ -10,10 +12,18 @@ type GoogleStatus = {
|
||||
configured: boolean;
|
||||
};
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
};
|
||||
|
||||
export const GoogleAccount = () => {
|
||||
const client = useClient();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, picture: null, configured: false });
|
||||
const { jobs, createJob, refetch } = useJobs({ type: 'gmail-sync' });
|
||||
const activeJob = jobs.find((j) => j.status === 'queued' || j.status === 'running');
|
||||
const lastJob = jobs[0];
|
||||
|
||||
const fetchStatus = () => {
|
||||
client
|
||||
@@ -37,6 +47,16 @@ export const GoogleAccount = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSync = async (year?: number) => {
|
||||
try {
|
||||
await createJob({ lane: 'google-api', type: 'gmail-sync', meta: year ? { year } : undefined });
|
||||
await refetch();
|
||||
toast.success("Gmail sync started — you'll receive an email when it's done");
|
||||
} catch {
|
||||
toast.error('Failed to start Gmail sync');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
const params = new URLSearchParams({
|
||||
token: client.token ?? '',
|
||||
@@ -69,6 +89,9 @@ export const GoogleAccount = () => {
|
||||
}
|
||||
|
||||
if (status.connected) {
|
||||
const currentStep = activeJob?.steps[activeJob.currentStep];
|
||||
const progress = currentStep?.progress;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
@@ -84,6 +107,64 @@ export const GoogleAccount = () => {
|
||||
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
|
||||
Officer has access to your Google Calendar, Gmail, and other enabled services.
|
||||
</p>
|
||||
{activeJob && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-duck-dark/60 dark:text-foreground/60 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">
|
||||
{activeJob.status === 'queued' ? 'Queued' : 'Syncing'}
|
||||
{progress?.label ? ` — ${progress.label}` : ''}
|
||||
</p>
|
||||
{progress && progress.total > 0 && (
|
||||
<div className="mt-1.5 h-1.5 rounded-full bg-duck-dark/10 dark:bg-foreground/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-duck-yellow transition-all duration-300"
|
||||
style={{ width: `${Math.round((progress.current / progress.total) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{progress && progress.total === 0 && progress.current > 0 && (
|
||||
<p className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{progress.current.toLocaleString()} emails processed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!activeJob && lastJob?.status === 'completed' && (
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
Last sync completed {formatTime(lastJob.completedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{!activeJob && lastJob?.status === 'failed' && (
|
||||
<div className="flex items-center gap-2 text-xs text-red-500">
|
||||
<XCircle className="h-3.5 w-3.5 shrink-0" />
|
||||
Last sync failed{lastJob.error ? `: ${lastJob.error}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!!activeJob}
|
||||
onClick={() => handleSync()}
|
||||
className="flex-1 h-11 cursor-pointer gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Sync Gmail Inbox
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!!activeJob}
|
||||
onClick={() => handleSync(2026)}
|
||||
className="flex-1 h-11 cursor-pointer gap-2"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Test Sync (2026)
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from './ChatHistory';
|
||||
export * from './Workspaces';
|
||||
export * from './Projects';
|
||||
export * from './Terminal';
|
||||
export * from './Email';
|
||||
|
||||
Reference in New Issue
Block a user