merge: fix/ux-leaf-d-new-pages into integration/ux-28-gaps
This commit is contained in:
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { DispatchPanel } from '@/components/agents/dispatch-panel';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
@@ -164,22 +165,25 @@ export default function AgentsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold">Agent Activity</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Every agent action, visible and reversible.
|
||||
</p>
|
||||
{feedback && (
|
||||
<p
|
||||
className={`mt-3 text-sm ${
|
||||
feedback.type === 'success' ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
role={feedback.type === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{feedback.message}
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Agent Activity</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Every agent action, visible and reversible.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DispatchPanel triggerLabel="+ New task" triggerVariant="default" />
|
||||
</div>
|
||||
{feedback && (
|
||||
<p
|
||||
className={`mt-3 text-sm ${
|
||||
feedback.type === 'success' ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
role={feedback.type === 'error' ? 'alert' : 'status'}
|
||||
>
|
||||
{feedback.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
{/* Agents list */}
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { LayoutGrid, Plus, Trash2, GripVertical, X, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface CanvasCard {
|
||||
id: string;
|
||||
canvas_id: string;
|
||||
type: 'note' | 'task' | 'image' | 'entity';
|
||||
entity_id?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
color?: string;
|
||||
z_index: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
interface CanvasConnection {
|
||||
id: string;
|
||||
source_card_id: string;
|
||||
target_card_id: string;
|
||||
label?: string;
|
||||
style: 'solid' | 'dashed' | 'dotted';
|
||||
}
|
||||
|
||||
interface Canvas {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mode: 'freeform' | 'graph';
|
||||
domain: string;
|
||||
tags: string[];
|
||||
cards: CanvasCard[];
|
||||
connections: CanvasConnection[];
|
||||
viewport?: { x: number; y: number; zoom: number };
|
||||
background?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
function CanvasBoard({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
|
||||
const [cards, setCards] = useState<CanvasCard[]>(canvas.cards || []);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||
const [viewport, setViewport] = useState(canvas.viewport || { x: 0, y: 0, zoom: 1 });
|
||||
const [editingCard, setEditingCard] = useState<CanvasCard | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const boardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent, cardId: string) => {
|
||||
e.preventDefault();
|
||||
const card = cards.find((c) => c.id === cardId);
|
||||
if (!card) return;
|
||||
setDragging(cardId);
|
||||
setDragOffset({
|
||||
x: e.clientX - card.x * viewport.zoom,
|
||||
y: e.clientY - card.y * viewport.zoom,
|
||||
});
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[cards, viewport.zoom]
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
const newX = (e.clientX - dragOffset.x) / viewport.zoom;
|
||||
const newY = (e.clientY - dragOffset.y) / viewport.zoom;
|
||||
setCards((prev) =>
|
||||
prev.map((c) => (c.id === dragging ? { ...c, x: Math.max(0, newX), y: Math.max(0, newY) } : c))
|
||||
);
|
||||
},
|
||||
[dragging, dragOffset, viewport.zoom]
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setDragging(null);
|
||||
}, []);
|
||||
|
||||
async function saveCardPosition(card: CanvasCard) {
|
||||
try {
|
||||
await fetch(`/api/canvases/${canvas.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
cards: cards.map((c) =>
|
||||
c.id === card.id
|
||||
? { ...c, x: card.x, y: card.y }
|
||||
: c
|
||||
),
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to save card position:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function addCard() {
|
||||
const newCard: CanvasCard = {
|
||||
id: crypto.randomUUID(),
|
||||
canvas_id: canvas.id,
|
||||
type: 'note',
|
||||
title: 'New note',
|
||||
content: '',
|
||||
x: 50 + Math.random() * 200,
|
||||
y: 50 + Math.random() * 200,
|
||||
width: 200,
|
||||
height: 150,
|
||||
rotation: 0,
|
||||
z_index: cards.length,
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
const updatedCards = [...cards, newCard];
|
||||
setCards(updatedCards);
|
||||
try {
|
||||
await fetch(`/api/canvases/${canvas.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cards: updatedCards }),
|
||||
});
|
||||
toast.success('Card added');
|
||||
} catch (err) {
|
||||
console.error('Failed to add card:', err);
|
||||
toast.error('Failed to add card');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCard(cardId: string) {
|
||||
const updatedCards = cards.filter((c) => c.id !== cardId);
|
||||
setCards(updatedCards);
|
||||
try {
|
||||
await fetch(`/api/canvases/${canvas.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cards: updatedCards }),
|
||||
});
|
||||
toast.success('Card removed');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete card:', err);
|
||||
toast.error('Failed to delete card');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCardEdit() {
|
||||
if (!editingCard) return;
|
||||
const updatedCards = cards.map((c) =>
|
||||
c.id === editingCard.id
|
||||
? { ...c, title: editTitle, content: editContent }
|
||||
: c
|
||||
);
|
||||
setCards(updatedCards);
|
||||
setEditingCard(null);
|
||||
try {
|
||||
await fetch(`/api/canvases/${canvas.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cards: updatedCards }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to save card:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(card: CanvasCard) {
|
||||
setEditingCard(card);
|
||||
setEditTitle(card.title || '');
|
||||
setEditContent(card.content || '');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold">{canvas.name}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setViewport((v) => ({ ...v, zoom: Math.max(0.25, v.zoom - 0.1) }))}
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="min-w-[3rem] text-center text-xs text-muted-foreground">
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setViewport((v) => ({ ...v, zoom: Math.min(3, v.zoom + 0.1) }))}
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 })}
|
||||
aria-label="Reset view"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={addCard}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Board */}
|
||||
<div
|
||||
ref={boardRef}
|
||||
className="relative flex-1 overflow-hidden bg-muted/30"
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerUp}
|
||||
style={{ cursor: dragging ? 'grabbing' : 'default' }}
|
||||
>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
{/* Connections */}
|
||||
<svg className="pointer-events-none absolute inset-0" style={{ width: 4000, height: 4000 }}>
|
||||
{canvas.connections.map((conn) => {
|
||||
const source = cards.find((c) => c.id === conn.source_card_id);
|
||||
const target = cards.find((c) => c.id === conn.target_card_id);
|
||||
if (!source || !target) return null;
|
||||
return (
|
||||
<line
|
||||
key={conn.id}
|
||||
x1={source.x + source.width / 2}
|
||||
y1={source.y + source.height / 2}
|
||||
x2={target.x + target.width / 2}
|
||||
y2={target.y + target.height / 2}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
strokeWidth={2}
|
||||
strokeDasharray={conn.style === 'dashed' ? '6,3' : conn.style === 'dotted' ? '2,2' : undefined}
|
||||
opacity={0.4}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Cards */}
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="absolute rounded-lg border bg-card shadow-sm transition-shadow hover:shadow-md"
|
||||
style={{
|
||||
left: card.x,
|
||||
top: card.y,
|
||||
width: card.width,
|
||||
height: card.height,
|
||||
zIndex: dragging === card.id ? 999 : card.z_index,
|
||||
transform: `rotate(${card.rotation}deg)`,
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex cursor-grab items-center gap-1 border-b bg-muted/30 px-2 py-1 rounded-t-lg"
|
||||
onPointerDown={(e) => handlePointerDown(e, card.id)}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-xs font-medium">
|
||||
{card.title || 'Untitled'}
|
||||
</span>
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEdit(card)}
|
||||
aria-label="Edit card"
|
||||
>
|
||||
<span className="text-xs">Edit</span>
|
||||
</button>
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => deleteCard(card.id)}
|
||||
aria-label="Delete card"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="overflow-auto p-2 text-xs text-muted-foreground" style={{ height: 'calc(100% - 28px)' }}>
|
||||
{card.content || 'No content'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{cards.length === 0 && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center">
|
||||
<LayoutGrid className="mx-auto h-8 w-8 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No cards yet</p>
|
||||
<Button className="mt-2" size="sm" onClick={addCard}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add your first card
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={!!editingCard} onOpenChange={(open) => !open && setEditingCard(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit card</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<Input
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
placeholder="Card title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Content</label>
|
||||
<Textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
placeholder="Card content"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={saveCardEdit}>Save</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CanvasPage() {
|
||||
const [canvases, setCanvases] = useState<Canvas[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeCanvas, setActiveCanvas] = useState<Canvas | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const fetchCanvases = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/canvases?sort=-updated');
|
||||
if (!res.ok) throw new Error('Unable to load canvases.');
|
||||
const data = await res.json();
|
||||
setCanvases(data.items || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch canvases:', err);
|
||||
setError('Unable to load canvases. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCanvases();
|
||||
}, [fetchCanvases]);
|
||||
|
||||
async function createCanvas() {
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/canvases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'New canvas',
|
||||
mode: 'freeform',
|
||||
domain: 'personal',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Unable to create canvas.');
|
||||
const canvas = await res.json();
|
||||
setCanvases((prev) => [canvas, ...prev]);
|
||||
setActiveCanvas(canvas);
|
||||
toast.success('Canvas created');
|
||||
} catch (err) {
|
||||
console.error('Failed to create canvas:', err);
|
||||
toast.error('Unable to create canvas');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCanvas(id: string) {
|
||||
try {
|
||||
await fetch(`/api/canvases/${id}`, { method: 'DELETE' });
|
||||
setCanvases((prev) => prev.filter((c) => c.id !== id));
|
||||
if (activeCanvas?.id === id) setActiveCanvas(null);
|
||||
toast.success('Canvas deleted');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete canvas:', err);
|
||||
toast.error('Unable to delete canvas');
|
||||
}
|
||||
}
|
||||
|
||||
async function openCanvas(canvas: Canvas) {
|
||||
try {
|
||||
const res = await fetch(`/api/canvases/${canvas.id}`);
|
||||
if (!res.ok) throw new Error('Unable to load canvas.');
|
||||
const full = await res.json();
|
||||
setActiveCanvas(full);
|
||||
} catch (err) {
|
||||
console.error('Failed to open canvas:', err);
|
||||
toast.error('Unable to open canvas');
|
||||
}
|
||||
}
|
||||
|
||||
if (activeCanvas) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
||||
<CanvasBoard canvas={activeCanvas} onBack={() => setActiveCanvas(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Canvas</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Freeform boards for visual thinking.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={createCanvas} disabled={creating}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{creating ? 'Creating...' : 'New canvas'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-muted-foreground">Loading canvases...</p>
|
||||
) : error ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-red-600" role="alert">{error}</p>
|
||||
<Button variant="outline" size="sm" className="mt-3" onClick={fetchCanvases}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : canvases.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<LayoutGrid className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-4 text-lg font-medium">No canvases yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Create a canvas to start visual brainstorming.
|
||||
</p>
|
||||
<Button className="mt-4" onClick={createCanvas} disabled={creating}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create your first canvas
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{canvases.map((canvas) => (
|
||||
<Card
|
||||
key={canvas.id}
|
||||
className="group cursor-pointer p-4 transition-colors hover:bg-accent/50"
|
||||
onClick={() => openCanvas(canvas)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="truncate font-medium">{canvas.name}</h3>
|
||||
{canvas.description && (
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{canvas.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{(canvas.cards || []).length} cards
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteCanvas(canvas.id);
|
||||
}}
|
||||
aria-label={`Delete ${canvas.name}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import { BookOpen, Plus, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { format, addDays, subDays } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/notes/note-editor').then((m) => m.NoteEditor),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function DailyNotesPage() {
|
||||
const [currentDate, setCurrentDate] = useState<Date>(new Date());
|
||||
const [note, setNote] = useState<Note | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<'Saved' | 'Saving' | 'Failed'>('Saved');
|
||||
|
||||
const dateStr = format(currentDate, 'yyyy-MM-dd');
|
||||
const displayDate = format(currentDate, 'EEEE, MMMM d, yyyy');
|
||||
|
||||
const fetchDailyNote = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setNote(null);
|
||||
try {
|
||||
const res = await fetch(`/api/notes/daily?date=${dateStr}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch daily note');
|
||||
const data = await res.json();
|
||||
if (data.note) {
|
||||
setNote(data.note);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch daily note:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dateStr]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDailyNote();
|
||||
}, [fetchDailyNote]);
|
||||
|
||||
async function handleCreateDailyNote() {
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await fetch('/api/notes/daily', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date: dateStr }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error('Failed to create daily note', text);
|
||||
toast.error('Failed to create daily note');
|
||||
return;
|
||||
}
|
||||
const createdNote = await res.json();
|
||||
setNote(createdNote);
|
||||
toast.success('Daily note created');
|
||||
} catch (err) {
|
||||
console.error('Failed to create daily note:', err);
|
||||
toast.error('Failed to create daily note');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(delta: number) {
|
||||
const next = delta > 0 ? addDays(currentDate, 1) : subDays(currentDate, 1);
|
||||
setCurrentDate(next);
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
setCurrentDate(new Date());
|
||||
}
|
||||
|
||||
async function handleSave(content: string) {
|
||||
if (!note) return;
|
||||
setSaveStatus('Saving');
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${note.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to save');
|
||||
setSaveStatus('Saved');
|
||||
} catch (err) {
|
||||
console.error('Failed to save note:', err);
|
||||
setSaveStatus('Failed');
|
||||
toast.error('Failed to save note');
|
||||
}
|
||||
}
|
||||
|
||||
const isToday = dateStr === format(new Date(), 'yyyy-MM-dd');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Daily Notes</h1>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{displayDate}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="Previous day"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
{!isToday && (
|
||||
<Button variant="outline" size="sm" onClick={goToToday}>
|
||||
Today
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(1)}
|
||||
aria-label="Next day"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="min-h-[500px]">
|
||||
{loading ? (
|
||||
<div className="flex h-[500px] items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : note ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{note.title}</h2>
|
||||
<span className="text-xs text-muted-foreground" role="status">
|
||||
{saveStatus}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="animate-pulse text-sm text-muted-foreground">
|
||||
Loading editor...
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<NoteEditor
|
||||
content={note.content || ''}
|
||||
onChange={(content) => {
|
||||
setNote({ ...note, content });
|
||||
handleSave(content);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[500px] flex-col items-center justify-center gap-4">
|
||||
<BookOpen className="h-12 w-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium">No daily note yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{isToday
|
||||
? 'Create your daily note to track what you accomplished today.'
|
||||
: 'No daily note exists for this date.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateDailyNote} disabled={creating}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{creating ? 'Creating...' : 'Create today\'s note'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { NetworkErrorBanner } from '@/components/network-error-banner';
|
||||
import { KeyboardShortcutsProvider } from '@/components/keyboard-shortcuts-provider';
|
||||
import { WebVitalsTracker } from '@/components/web-vitals-tracker';
|
||||
import { MobileBottomNav } from '@/components/mobile-bottom-nav';
|
||||
import { DispatchPanel } from '@/components/agents/dispatch-panel';
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -23,6 +24,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
</div>
|
||||
</div>
|
||||
<MobileBottomNav />
|
||||
{/* Floating AI dispatch button */}
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<DispatchPanel
|
||||
triggerLabel="Ask AI"
|
||||
triggerVariant="default"
|
||||
triggerClassName="h-12 w-12 rounded-full shadow-lg md:h-auto md:w-auto md:rounded-md md:px-4 md:py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="a11y-announcer" />
|
||||
</KeyboardShortcutsProvider>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
// See AGENTS.md for full rules.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { withAuth } from '@/lib/auth';
|
||||
import { withAuth, createErrorResponse } from '@/lib/auth';
|
||||
import { createPocketBaseClient } from '@/lib/pocketbase';
|
||||
import { createAgentTaskSchema } from '@project-e/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
// GET /api/agent-tasks — List agent tasks
|
||||
export const GET = withAuth(async (request: NextRequest) => {
|
||||
@@ -27,3 +29,24 @@ export const GET = withAuth(async (request: NextRequest) => {
|
||||
perPage: result.perPage,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/agent-tasks — Create a new agent task
|
||||
export const POST = withAuth(async (request: NextRequest) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = createAgentTaskSchema.parse(body);
|
||||
|
||||
const pb = createPocketBaseClient();
|
||||
const task = await pb.collection('agent_tasks').create({
|
||||
...data,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
return NextResponse.json(task, { status: 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return createErrorResponse('VALIDATION_ERROR', 'Invalid input', 400, error.issues);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Bot, Sparkles, X, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'active' | 'disabled';
|
||||
permission_tier: string;
|
||||
}
|
||||
|
||||
interface DispatchPanelProps {
|
||||
/** Optional trigger label override */
|
||||
triggerLabel?: string;
|
||||
/** Optional variant for the trigger button */
|
||||
triggerVariant?: 'default' | 'outline' | 'ghost';
|
||||
/** Optional class name for the trigger button */
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
export function DispatchPanel({
|
||||
triggerLabel = 'Ask AI',
|
||||
triggerVariant = 'default',
|
||||
triggerClassName,
|
||||
}: DispatchPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string>('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [entityRef, setEntityRef] = useState('');
|
||||
const [dispatching, setDispatching] = useState(false);
|
||||
const [loadingAgents, setLoadingAgents] = useState(false);
|
||||
|
||||
const fetchAgents = useCallback(async () => {
|
||||
setLoadingAgents(true);
|
||||
try {
|
||||
const res = await fetch('/api/agents');
|
||||
if (!res.ok) throw new Error('Failed to fetch agents');
|
||||
const data = await res.json();
|
||||
const activeAgents = (data.items || []).filter(
|
||||
(a: Agent) => a.status === 'active'
|
||||
);
|
||||
setAgents(activeAgents);
|
||||
if (activeAgents.length > 0 && !selectedAgentId) {
|
||||
setSelectedAgentId(activeAgents[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch agents:', err);
|
||||
} finally {
|
||||
setLoadingAgents(false);
|
||||
}
|
||||
}, [selectedAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchAgents();
|
||||
}
|
||||
}, [open, fetchAgents]);
|
||||
|
||||
async function handleDispatch() {
|
||||
if (!selectedAgentId || !prompt.trim()) {
|
||||
toast.error('Please select an agent and enter a prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
setDispatching(true);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
agent_id: selectedAgentId,
|
||||
task_type: 'custom',
|
||||
input: { prompt: prompt.trim() },
|
||||
};
|
||||
|
||||
if (entityRef.trim()) {
|
||||
// Parse entity reference: "type:id" or just a free-form reference
|
||||
body.entity_type = 'reference';
|
||||
body.entity_id = entityRef.trim();
|
||||
}
|
||||
|
||||
const res = await fetch('/api/agent-tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error('Failed to dispatch task:', text);
|
||||
toast.error('Failed to dispatch task');
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Task dispatched — see Agent Activity for results');
|
||||
setPrompt('');
|
||||
setEntityRef('');
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to dispatch task:', err);
|
||||
toast.error('Failed to dispatch task');
|
||||
} finally {
|
||||
setDispatching(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant={triggerVariant}
|
||||
className={triggerClassName}
|
||||
aria-label={triggerLabel}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
Dispatch AI Agent
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Send a task to an AI agent and view results in Agent Activity.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* Agent selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Agent</label>
|
||||
{loadingAgents ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading agents...
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No active agents available
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedAgentId}
|
||||
onValueChange={setSelectedAgentId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{agent.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({agent.permission_tier})
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Prompt</label>
|
||||
<Textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="What should the agent do?"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Entity reference (optional) */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Entity reference{' '}
|
||||
<span className="text-xs text-muted-foreground">(optional)</span>
|
||||
</label>
|
||||
<Input
|
||||
value={entityRef}
|
||||
onChange={(e) => setEntityRef(e.target.value)}
|
||||
placeholder="e.g. task:abc123 or project:xyz"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Reference a specific entity the agent should work on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dispatch button */}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleDispatch}
|
||||
disabled={dispatching || !selectedAgentId || !prompt.trim()}
|
||||
>
|
||||
{dispatching ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Dispatching...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Dispatch
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
Flame,
|
||||
FolderKanban,
|
||||
NotebookPen,
|
||||
BookOpen,
|
||||
Share2,
|
||||
LayoutGrid,
|
||||
CalendarDays,
|
||||
Search,
|
||||
Bot,
|
||||
@@ -41,7 +43,9 @@ const navItems = [
|
||||
{ href: '/habits', label: 'Habits', icon: Flame },
|
||||
{ href: '/projects', label: 'Projects', icon: FolderKanban },
|
||||
{ href: '/notes', label: 'Notes', icon: NotebookPen },
|
||||
{ href: '/daily-notes', label: 'Daily Note', icon: BookOpen },
|
||||
{ href: '/graph', label: 'Graph', icon: Share2 },
|
||||
{ href: '/canvas', label: 'Canvas', icon: LayoutGrid },
|
||||
{ href: '/calendar', label: 'Calendar', icon: CalendarDays },
|
||||
{ href: '/search', label: 'Search', icon: Search },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user