Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ DATABASE_URL=sqlite:///./workmate.db

# AI / LLM
GEMINI_API_KEY=your-gemini-api-key
VOYAGE_API_KEY=your-voyageai-api-key
VOYAGE_API_KEY=your-voyageai-api-key

# Notion (direct API access — optional, JSON import works without this)
# Notion
NOTION_TOKEN=your-notion-api-token
NOTION_OAUTH_CLIENT_ID=your-notion-oauth-client-id
NOTION_OAUTH_CLIENT_SECRET=your-notion-oauth-client-secret
NOTION_REDIRECT_URI=http://localhost:8000/api/notion/callback
NOTION_ENCRYPTION_KEY=your-fernet-encryption-key
60 changes: 37 additions & 23 deletions frontend/src/app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,10 @@ import { Card } from './ui/card';
import { Button } from './ui/button';
import { useAuth } from '../contexts/AuthContext';
import { useIsAdmin } from '../../hooks/useIsAdmin';
import { listConversations, deleteConversation, renameConversation } from '../../services/api';
import { listConversations, deleteConversation, renameConversation, getWorkspaces } from '../../services/api';
import { toast } from 'sonner';
import type { ConversationSummary, NotionWorkspace } from '../../types/chat';

const mockWorkspaces: NotionWorkspace[] = [
{ id: '1', name: 'Product Requirements', pageCount: 24, connected: true },
{ id: '2', name: 'Engineering Docs', pageCount: 156, connected: true },
{ id: '3', name: 'Team Wiki', pageCount: 89, connected: true },
];

interface SidebarProps {
activeConversationId: number | null;
onSelectConversation: (id: number | null, title?: string) => void;
Expand Down Expand Up @@ -63,11 +57,15 @@ export function Sidebar({
const [searchQuery, setSearchQuery] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editingTitle, setEditingTitle] = useState('');
const [workspaces, setWorkspaces] = useState<NotionWorkspace[]>([]);

useEffect(() => {
listConversations()
.then(setConversations)
.catch((err) => console.error('Failed to load conversations:', err));
getWorkspaces()
.then(setWorkspaces)
.catch(() => {}); // Silently fail if no workspaces
}, [refreshKey]);

const handleDelete = async (e: React.MouseEvent, id: number) => {
Expand Down Expand Up @@ -270,25 +268,41 @@ export function Sidebar({
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Connected Workspaces</h2>
</div>
<div className="space-y-1.5">
{mockWorkspaces.map((workspace) => (
<Card
key={workspace.id}
className="p-2.5 hover:bg-white dark:hover:bg-slate-800 transition-colors cursor-pointer border-slate-200 dark:border-slate-700"
{workspaces.length === 0 ? (
<Link
to="/settings"
className="block text-xs text-slate-400 hover:text-purple-500 text-center py-3 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<h3 className="font-medium text-sm text-slate-900 dark:text-slate-100">{workspace.name}</h3>
Connect a Notion workspace
</Link>
) : (
workspaces.map((workspace) => (
<Link key={workspace.id} to="/settings">
<Card
className="p-2.5 hover:bg-white dark:hover:bg-slate-800 transition-colors cursor-pointer border-slate-200 dark:border-slate-700"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
workspace.sync_status === 'syncing' ? 'bg-yellow-500 animate-pulse' :
workspace.sync_status === 'error' ? 'bg-red-500' : 'bg-green-500'
}`} />
<h3 className="font-medium text-sm text-slate-900 dark:text-slate-100">
{workspace.workspace_name}
</h3>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{workspace.sync_status === 'syncing' ? 'Syncing...' :
workspace.last_synced_at ? `Synced ${formatTime(workspace.last_synced_at)}` : 'Pending sync'}
</p>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
{workspace.pageCount} pages indexed
</p>
<ChevronRight className="w-4 h-4 text-slate-400" />
</div>
<ChevronRight className="w-4 h-4 text-slate-400" />
</div>
</Card>
))}
</Card>
</Link>
))
)}
</div>
</div>

Expand Down
215 changes: 202 additions & 13 deletions frontend/src/app/pages/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Sun, Moon, Monitor, Link2 } from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Sun, Moon, Monitor, Link2, RefreshCw, Unplug, Plus, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
Expand All @@ -20,14 +20,125 @@ import {
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from 'next-themes';
import { updateProfile, deleteAccount } from '../../services/auth';
import { getNotionAuthUrl, getWorkspaces, disconnectWorkspace, syncWorkspace } from '../../services/api';
import type { NotionWorkspace } from '../../types/chat';

export function SettingsPage() {
const { user, logout, updateUser } = useAuth();
const { theme, setTheme } = useTheme();
const navigate = useNavigate();

const [searchParams, setSearchParams] = useSearchParams();
const [name, setName] = useState(user?.name ?? '');
const [saving, setSaving] = useState(false);
const [workspaces, setWorkspaces] = useState<NotionWorkspace[]>([]);
const [loadingWorkspaces, setLoadingWorkspaces] = useState(true);
const [connecting, setConnecting] = useState(false);
const [syncingId, setSyncingId] = useState<number | null>(null);

useEffect(() => {
loadWorkspaces();
// Handle redirect from Notion OAuth callback
if (searchParams.get('notion') === 'connected') {
toast.success('Notion workspace connected! Ingestion is running in the background.');
searchParams.delete('notion');
setSearchParams(searchParams, { replace: true });
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps

// Poll for status updates while any workspace is syncing
const isSyncingRef = useRef(false);
isSyncingRef.current = workspaces.some((w) => w.sync_status === 'syncing');

useEffect(() => {
if (!isSyncingRef.current) return;

const poll = () => {
getWorkspaces().then((ws) => {
setWorkspaces(ws);
if (!ws.some((w) => w.sync_status === 'syncing')) {
const failed = ws.find((w) => w.sync_status === 'error');
if (failed) {
toast.error(`Sync failed for ${failed.workspace_name}`);
} else {
toast.success('Sync complete!');
}
}
}).catch(() => {});
};

// Poll immediately, then every 5 seconds
const timeout = setTimeout(poll, 2000);
const interval = setInterval(poll, 5000);

return () => {
clearTimeout(timeout);
clearInterval(interval);
};
}, [isSyncingRef.current]); // eslint-disable-line react-hooks/exhaustive-deps

const loadWorkspaces = async () => {
try {
const ws = await getWorkspaces();
setWorkspaces(ws);
} catch {
// Silently fail — user may not have any workspaces
} finally {
setLoadingWorkspaces(false);
}
};

const handleConnectNotion = async () => {
setConnecting(true);
try {
const url = await getNotionAuthUrl();
window.location.href = url;
} catch {
toast.error('Failed to initiate Notion connection');
setConnecting(false);
}
};

const handleDisconnect = async (wsId: number) => {
try {
await disconnectWorkspace(wsId);
setWorkspaces((prev) => prev.filter((w) => w.id !== wsId));
toast.success('Workspace disconnected');
} catch {
toast.error('Failed to disconnect workspace');
}
};

const handleSync = async (wsId: number) => {
setSyncingId(wsId);
try {
const result = await syncWorkspace(wsId);
if (result.status === 'already_syncing') {
toast.info('Sync is already in progress');
} else {
toast.success('Sync started — this may take a few minutes');
setWorkspaces((prev) =>
prev.map((w) => (w.id === wsId ? { ...w, sync_status: 'syncing' } : w))
);
}
} catch {
toast.error('Failed to start sync');
} finally {
setSyncingId(null);
}
};

const formatTimeAgo = (dateStr: string | null) => {
if (!dateStr) return 'Never';
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
};

const handleSaveName = async () => {
const trimmed = name.trim();
Expand Down Expand Up @@ -147,17 +258,95 @@ export function SettingsPage() {
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-3">
Connected Accounts
</h2>
<div className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-600 p-3">
<div className="flex items-center gap-3">
<Link2 className="h-5 w-5 text-slate-500 dark:text-slate-400" />
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">Notion</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Coming soon</p>
<div className="space-y-3">
{loadingWorkspaces ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-slate-400" />
</div>
</div>
<Button variant="outline" disabled className="dark:border-slate-600 dark:text-slate-400">
Connect
</Button>
) : (
<>
{workspaces.map((ws) => (
<div
key={ws.id}
className="flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-600 p-3"
>
<div className="flex items-center gap-3">
<Link2 className="h-5 w-5 text-purple-500" />
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{ws.workspace_name}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Connected {formatTimeAgo(ws.connected_at)}
{ws.last_synced_at && ` · Last synced ${formatTimeAgo(ws.last_synced_at)}`}
{ws.sync_status === 'syncing' && (
<span className="ml-1 text-purple-500">· Syncing...</span>
)}
{ws.sync_status === 'error' && (
<span className="ml-1 text-red-500">· Sync failed</span>
)}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSync(ws.id)}
disabled={syncingId === ws.id || ws.sync_status === 'syncing'}
className="dark:border-slate-600 dark:text-slate-300"
>
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${syncingId === ws.id || ws.sync_status === 'syncing' ? 'animate-spin' : ''}`} />
Sync
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-red-500 hover:text-red-600 dark:border-slate-600"
>
<Unplug className="h-3.5 w-3.5 mr-1" />
Disconnect
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Disconnect {ws.workspace_name}?</AlertDialogTitle>
<AlertDialogDescription>
This will remove your connection to this Notion workspace.
If no other users are connected, the workspace data will also be removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDisconnect(ws.id)}
className="bg-red-600 hover:bg-red-700 text-white"
>
Disconnect
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
<Button
variant="outline"
onClick={handleConnectNotion}
disabled={connecting}
className="w-full dark:border-slate-600 dark:text-slate-300"
>
{connecting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{workspaces.length > 0 ? 'Connect another Notion workspace' : 'Connect Notion workspace'}
</Button>
</>
)}
</div>
</Card>

Expand Down
42 changes: 33 additions & 9 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,38 @@ export async function uploadFiles(files: File[]): Promise<UploadResponse[]> {
return res.json();
}

/**
* Fetch connected Notion workspaces.
* Currently returns mock data.
*/
// --- Notion workspace endpoints ---

export async function getNotionAuthUrl(): Promise<string> {
const res = await fetch(`${BASE_URL}/notion/connect`, {
headers: authHeaders(),
});
if (!res.ok) throw new Error('Failed to get Notion auth URL');
const data = await res.json();
return data.authorization_url;
}

export async function getWorkspaces(): Promise<NotionWorkspace[]> {
return [
{ id: '1', name: 'Product Requirements', pageCount: 24, connected: true },
{ id: '2', name: 'Engineering Docs', pageCount: 156, connected: true },
{ id: '3', name: 'Team Wiki', pageCount: 89, connected: true },
];
const res = await fetch(`${BASE_URL}/notion/workspaces`, {
headers: authHeaders(),
});
if (!res.ok) throw new Error('Failed to fetch workspaces');
return res.json();
}

export async function disconnectWorkspace(workspaceId: number): Promise<void> {
const res = await fetch(`${BASE_URL}/notion/workspaces/${workspaceId}`, {
method: 'DELETE',
headers: authHeaders(),
});
if (!res.ok) throw new Error('Failed to disconnect workspace');
}

export async function syncWorkspace(workspaceId: number): Promise<{ status: string }> {
const res = await fetch(`${BASE_URL}/notion/workspaces/${workspaceId}/sync`, {
method: 'POST',
headers: authHeaders(),
});
if (!res.ok) throw new Error('Failed to sync workspace');
return res.json();
}
Loading