Skip to content
13 changes: 13 additions & 0 deletions cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,16 @@ export interface DispatchResponse {
};
}

export interface DispatchImagePromptRequest {
title: string;
tags: string[];
tldr: string;
format: DispatchFormat;
}

export interface DispatchImagePromptResponse {
prompt: string;
model: string;
tokensUsed: { input: number; output: number };
}

101 changes: 101 additions & 0 deletions dashboard/src/components/dispatch/CoverImagePromptSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Sparkles, Loader2, AlertCircle, Copy, Check } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { generateDispatchImagePrompt } from '@/lib/api';
import type { DispatchFormat } from '@/lib/api';

interface CoverImagePromptSectionProps {
title: string;
tags: string[];
tldr: string;
format: DispatchFormat;
}

export function CoverImagePromptSection({ title, tags, tldr, format }: CoverImagePromptSectionProps) {
const [copied, setCopied] = useState(false);

const mutation = useMutation({
mutationFn: () => generateDispatchImagePrompt({ title, tags, tldr, format }),
});

function handleCopy() {
if (!mutation.data) return;
void navigator.clipboard.writeText(mutation.data.prompt).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success('Prompt copied to clipboard');
});
}

function handleRegenerate() {
mutation.reset();
mutation.mutate();
}

return (
<div className="shrink-0 border-t px-4 py-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Cover image prompt</p>

{!mutation.data && !mutation.isPending && !mutation.isError && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => mutation.mutate()}
>
<Sparkles className="h-3.5 w-3.5" />
Get cover image prompt
</Button>
)}

{mutation.isPending && (
<Button variant="outline" size="sm" className="gap-1.5" disabled>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Generating prompt...
</Button>
)}

{mutation.isError && (
<div className="space-y-2">
<Alert variant="destructive" className="py-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{mutation.error instanceof Error ? mutation.error.message : 'Failed to generate prompt.'}
</AlertDescription>
</Alert>
<Button variant="outline" size="sm" onClick={() => mutation.mutate()}>
Retry
</Button>
</div>
)}

{mutation.data && (
<div className="space-y-2">
<Textarea
readOnly
rows={4}
value={mutation.data.prompt}
className="resize-none text-sm"
/>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCopy}>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? 'Copied' : 'Copy prompt'}
</Button>
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline transition-colors rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={handleRegenerate}
>
Regenerate
</button>
</div>
</div>
)}
</div>
);
}
38 changes: 25 additions & 13 deletions dashboard/src/components/dispatch/DispatchDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { generateDispatch } from '@/lib/api';
import { PostPreview } from './PostPreview';
import { PostOverlay } from './PostOverlay';
import type { Insight } from '@/lib/types';
import type { DispatchTone, DispatchFormat, DispatchResponse } from '@/lib/api';

Expand Down Expand Up @@ -123,10 +123,11 @@ export function DispatchDrawer({
const [tone, setTone] = useState<DispatchTone>('technical');
const [includeSessionBackground, setIncludeSessionBackground] = useState(false);
const [result, setResult] = useState<DispatchResponse | null>(null);
const [overlayOpen, setOverlayOpen] = useState(false);

const mutation = useMutation({
mutationFn: generateDispatch,
onSuccess: (data) => setResult(data),
onSuccess: (data) => { setResult(data); setOverlayOpen(true); },
});

const sensors = useSensors(
Expand Down Expand Up @@ -155,6 +156,7 @@ export function DispatchDrawer({

function handleClose() {
onOpenChange(false);
setOverlayOpen(false);
setResult(null);
mutation.reset();
setFormat('blog');
Expand All @@ -167,6 +169,7 @@ export function DispatchDrawer({
const contextTooLong = context.length > 500;

return (
<>
<Sheet open={open} onOpenChange={handleClose}>
<SheetContent
side="right"
Expand All @@ -179,10 +182,7 @@ export function DispatchDrawer({
</SheetDescription>
</SheetHeader>

{result ? (
<PostPreview result={result} />
) : (
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{/* Selected insights with drag-to-reorder */}
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
Expand Down Expand Up @@ -330,10 +330,9 @@ export function DispatchDrawer({
</div>
)}
</div>
)}

{/* Footer */}
{!result && (
{!result ? (
<div className="shrink-0 px-4 py-3 border-t">
<Button
className="w-full"
Expand All @@ -355,13 +354,17 @@ export function DispatchDrawer({
</p>
)}
</div>
)}

{result && (
<div className="shrink-0 px-4 py-3 border-t">
) : (
<div className="shrink-0 px-4 py-3 border-t flex gap-2">
<Button
className="flex-1"
onClick={() => setOverlayOpen(true)}
>
View post
</Button>
<Button
variant="outline"
className="w-full"
className="flex-1"
onClick={() => { setResult(null); mutation.reset(); }}
>
Regenerate
Expand All @@ -370,5 +373,14 @@ export function DispatchDrawer({
)}
</SheetContent>
</Sheet>

{result && (
<PostOverlay
open={overlayOpen}
onClose={() => setOverlayOpen(false)}
result={result}
/>
)}
</>
);
}
61 changes: 61 additions & 0 deletions dashboard/src/components/dispatch/PostOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { X } from 'lucide-react';
import { Dialog as DialogPrimitive } from 'radix-ui';
import { PostPreview } from './PostPreview';
import { CoverImagePromptSection } from './CoverImagePromptSection';
import type { DispatchResponse } from '@/lib/api';

interface PostOverlayProps {
open: boolean;
onClose: () => void;
result: DispatchResponse;
}

export function PostOverlay({ open, onClose, result }: PostOverlayProps) {
const { title, tags, tldr } = result.frontmatter;

return (
<DialogPrimitive.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose(); }}>
<DialogPrimitive.Portal>
{/* Backdrop — z-[60] to sit above the Sheet (z-50) */}
<DialogPrimitive.Overlay className="fixed inset-0 z-[60] bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />

{/* Full-screen content — z-[61] so it renders above the backdrop */}
<DialogPrimitive.Content
aria-describedby="post-overlay-desc"
className="fixed inset-0 z-[61] flex flex-col bg-background outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogPrimitive.Title className="sr-only">{title || 'Post preview'}</DialogPrimitive.Title>
<p id="post-overlay-desc" className="sr-only">
Full-screen preview of the generated post. Use Escape or the close button to dismiss.
</p>

{/* Header */}
<div className="shrink-0 flex items-center justify-between border-b px-4 py-3">
<p className="text-sm font-medium truncate max-w-[80%]">{title || 'Generated post'}</p>
<button
type="button"
onClick={onClose}
aria-label="Close preview"
className="rounded-xs text-muted-foreground hover:text-foreground opacity-70 hover:opacity-100 transition-opacity focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 ring-offset-background"
>
<X className="h-5 w-5" />
</button>
</div>

{/* Scrollable post preview */}
<div className="flex-1 overflow-hidden">
<PostPreview result={result} />
</div>

{/* Cover image prompt section */}
<CoverImagePromptSection
title={title}
tags={tags}
tldr={tldr}
format={result.format}
/>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
20 changes: 20 additions & 0 deletions dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,26 @@ export function generateDispatch(body: DispatchRequest): Promise<DispatchRespons
});
}

export interface DispatchImagePromptRequest {
title: string;
tags: string[];
tldr: string;
format: DispatchFormat;
}

export interface DispatchImagePromptResponse {
prompt: string;
model: string;
tokensUsed: { input: number; output: number };
}

export function generateDispatchImagePrompt(body: DispatchImagePromptRequest): Promise<DispatchImagePromptResponse> {
return request<DispatchImagePromptResponse>('/dispatch/image-prompt', {
method: 'POST',
body: JSON.stringify(body),
});
}

// ── Analysis Queue ────────────────────────────────────────────────────────────

export interface AnalysisQueueItem {
Expand Down
Loading
Loading