1- import { useState } from 'react' ;
2- import { useNavigate } from 'react-router-dom' ;
3- import { Sun , Moon , Monitor , Link2 } from 'lucide-react' ;
1+ import { useState , useEffect } from 'react' ;
2+ import { useNavigate , useSearchParams } from 'react-router-dom' ;
3+ import { Sun , Moon , Monitor , Link2 , RefreshCw , Unplug , Plus , Loader2 } from 'lucide-react' ;
44import { toast } from 'sonner' ;
55import { Button } from '../components/ui/button' ;
66import { Card } from '../components/ui/card' ;
@@ -20,14 +20,117 @@ import {
2020import { useAuth } from '../contexts/AuthContext' ;
2121import { useTheme } from 'next-themes' ;
2222import { updateProfile , deleteAccount } from '../../services/auth' ;
23+ import { getNotionAuthUrl , getWorkspaces , disconnectWorkspace , syncWorkspace } from '../../services/api' ;
24+ import type { NotionWorkspace } from '../../types/chat' ;
2325
2426export function SettingsPage ( ) {
2527 const { user, logout, updateUser } = useAuth ( ) ;
2628 const { theme, setTheme } = useTheme ( ) ;
2729 const navigate = useNavigate ( ) ;
2830
31+ const [ searchParams , setSearchParams ] = useSearchParams ( ) ;
2932 const [ name , setName ] = useState ( user ?. name ?? '' ) ;
3033 const [ saving , setSaving ] = useState ( false ) ;
34+ const [ workspaces , setWorkspaces ] = useState < NotionWorkspace [ ] > ( [ ] ) ;
35+ const [ loadingWorkspaces , setLoadingWorkspaces ] = useState ( true ) ;
36+ const [ connecting , setConnecting ] = useState ( false ) ;
37+ const [ syncingId , setSyncingId ] = useState < number | null > ( null ) ;
38+
39+ useEffect ( ( ) => {
40+ loadWorkspaces ( ) ;
41+ // Handle redirect from Notion OAuth callback
42+ if ( searchParams . get ( 'notion' ) === 'connected' ) {
43+ toast . success ( 'Notion workspace connected! Ingestion is running in the background.' ) ;
44+ searchParams . delete ( 'notion' ) ;
45+ setSearchParams ( searchParams , { replace : true } ) ;
46+ }
47+ } , [ ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
48+
49+ // Poll for status updates while any workspace is syncing
50+ useEffect ( ( ) => {
51+ const isSyncing = workspaces . some ( ( w ) => w . sync_status === 'syncing' ) ;
52+ if ( ! isSyncing ) return ;
53+
54+ const interval = setInterval ( ( ) => {
55+ getWorkspaces ( ) . then ( ( ws ) => {
56+ setWorkspaces ( ws ) ;
57+ // Stop polling if nothing is syncing anymore
58+ if ( ! ws . some ( ( w ) => w . sync_status === 'syncing' ) ) {
59+ const failed = ws . find ( ( w ) => w . sync_status === 'error' ) ;
60+ if ( failed ) {
61+ toast . error ( `Sync failed for ${ failed . workspace_name } ` ) ;
62+ } else {
63+ toast . success ( 'Sync complete!' ) ;
64+ }
65+ }
66+ } ) . catch ( ( ) => { } ) ;
67+ } , 10000 ) ;
68+
69+ return ( ) => clearInterval ( interval ) ;
70+ } , [ workspaces ] ) ;
71+
72+ const loadWorkspaces = async ( ) => {
73+ try {
74+ const ws = await getWorkspaces ( ) ;
75+ setWorkspaces ( ws ) ;
76+ } catch {
77+ // Silently fail — user may not have any workspaces
78+ } finally {
79+ setLoadingWorkspaces ( false ) ;
80+ }
81+ } ;
82+
83+ const handleConnectNotion = async ( ) => {
84+ setConnecting ( true ) ;
85+ try {
86+ const url = await getNotionAuthUrl ( ) ;
87+ window . location . href = url ;
88+ } catch {
89+ toast . error ( 'Failed to initiate Notion connection' ) ;
90+ setConnecting ( false ) ;
91+ }
92+ } ;
93+
94+ const handleDisconnect = async ( wsId : number ) => {
95+ try {
96+ await disconnectWorkspace ( wsId ) ;
97+ setWorkspaces ( ( prev ) => prev . filter ( ( w ) => w . id !== wsId ) ) ;
98+ toast . success ( 'Workspace disconnected' ) ;
99+ } catch {
100+ toast . error ( 'Failed to disconnect workspace' ) ;
101+ }
102+ } ;
103+
104+ const handleSync = async ( wsId : number ) => {
105+ setSyncingId ( wsId ) ;
106+ try {
107+ const result = await syncWorkspace ( wsId ) ;
108+ if ( result . status === 'already_syncing' ) {
109+ toast . info ( 'Sync is already in progress' ) ;
110+ } else {
111+ toast . success ( 'Sync started — this may take a few minutes' ) ;
112+ setWorkspaces ( ( prev ) =>
113+ prev . map ( ( w ) => ( w . id === wsId ? { ...w , sync_status : 'syncing' } : w ) )
114+ ) ;
115+ }
116+ } catch {
117+ toast . error ( 'Failed to start sync' ) ;
118+ } finally {
119+ setSyncingId ( null ) ;
120+ }
121+ } ;
122+
123+ const formatTimeAgo = ( dateStr : string | null ) => {
124+ if ( ! dateStr ) return 'Never' ;
125+ const diff = Date . now ( ) - new Date ( dateStr ) . getTime ( ) ;
126+ const mins = Math . floor ( diff / 60000 ) ;
127+ if ( mins < 1 ) return 'Just now' ;
128+ if ( mins < 60 ) return `${ mins } m ago` ;
129+ const hours = Math . floor ( mins / 60 ) ;
130+ if ( hours < 24 ) return `${ hours } h ago` ;
131+ const days = Math . floor ( hours / 24 ) ;
132+ return `${ days } d ago` ;
133+ } ;
31134
32135 const handleSaveName = async ( ) => {
33136 const trimmed = name . trim ( ) ;
@@ -147,17 +250,95 @@ export function SettingsPage() {
147250 < h2 className = "text-lg font-semibold text-slate-900 dark:text-slate-100 mb-3" >
148251 Connected Accounts
149252 </ h2 >
150- < div className = "flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-600 p-3" >
151- < div className = "flex items-center gap-3" >
152- < Link2 className = "h-5 w-5 text-slate-500 dark:text-slate-400" />
153- < div >
154- < p className = "text-sm font-medium text-slate-900 dark:text-slate-100" > Notion</ p >
155- < p className = "text-xs text-slate-500 dark:text-slate-400" > Coming soon</ p >
253+ < div className = "space-y-3" >
254+ { loadingWorkspaces ? (
255+ < div className = "flex items-center justify-center py-4" >
256+ < Loader2 className = "h-5 w-5 animate-spin text-slate-400" />
156257 </ div >
157- </ div >
158- < Button variant = "outline" disabled className = "dark:border-slate-600 dark:text-slate-400" >
159- Connect
160- </ Button >
258+ ) : (
259+ < >
260+ { workspaces . map ( ( ws ) => (
261+ < div
262+ key = { ws . id }
263+ className = "flex items-center justify-between rounded-lg border border-slate-200 dark:border-slate-600 p-3"
264+ >
265+ < div className = "flex items-center gap-3" >
266+ < Link2 className = "h-5 w-5 text-purple-500" />
267+ < div >
268+ < p className = "text-sm font-medium text-slate-900 dark:text-slate-100" >
269+ { ws . workspace_name }
270+ </ p >
271+ < p className = "text-xs text-slate-500 dark:text-slate-400" >
272+ Connected { formatTimeAgo ( ws . connected_at ) }
273+ { ws . last_synced_at && ` · Last synced ${ formatTimeAgo ( ws . last_synced_at ) } ` }
274+ { ws . sync_status === 'syncing' && (
275+ < span className = "ml-1 text-purple-500" > · Syncing...</ span >
276+ ) }
277+ { ws . sync_status === 'error' && (
278+ < span className = "ml-1 text-red-500" > · Sync failed</ span >
279+ ) }
280+ </ p >
281+ </ div >
282+ </ div >
283+ < div className = "flex gap-2" >
284+ < Button
285+ variant = "outline"
286+ size = "sm"
287+ onClick = { ( ) => handleSync ( ws . id ) }
288+ disabled = { syncingId === ws . id || ws . sync_status === 'syncing' }
289+ className = "dark:border-slate-600 dark:text-slate-300"
290+ >
291+ < RefreshCw className = { `h-3.5 w-3.5 mr-1 ${ syncingId === ws . id || ws . sync_status === 'syncing' ? 'animate-spin' : '' } ` } />
292+ Sync
293+ </ Button >
294+ < AlertDialog >
295+ < AlertDialogTrigger asChild >
296+ < Button
297+ variant = "outline"
298+ size = "sm"
299+ className = "text-red-500 hover:text-red-600 dark:border-slate-600"
300+ >
301+ < Unplug className = "h-3.5 w-3.5 mr-1" />
302+ Disconnect
303+ </ Button >
304+ </ AlertDialogTrigger >
305+ < AlertDialogContent >
306+ < AlertDialogHeader >
307+ < AlertDialogTitle > Disconnect { ws . workspace_name } ?</ AlertDialogTitle >
308+ < AlertDialogDescription >
309+ This will remove your connection to this Notion workspace.
310+ If no other users are connected, the workspace data will also be removed.
311+ </ AlertDialogDescription >
312+ </ AlertDialogHeader >
313+ < AlertDialogFooter >
314+ < AlertDialogCancel > Cancel</ AlertDialogCancel >
315+ < AlertDialogAction
316+ onClick = { ( ) => handleDisconnect ( ws . id ) }
317+ className = "bg-red-600 hover:bg-red-700 text-white"
318+ >
319+ Disconnect
320+ </ AlertDialogAction >
321+ </ AlertDialogFooter >
322+ </ AlertDialogContent >
323+ </ AlertDialog >
324+ </ div >
325+ </ div >
326+ ) ) }
327+ < Button
328+ variant = "outline"
329+ onClick = { handleConnectNotion }
330+ disabled = { connecting }
331+ className = "w-full dark:border-slate-600 dark:text-slate-300"
332+ >
333+ { connecting ? (
334+ < Loader2 className = "h-4 w-4 mr-2 animate-spin" />
335+ ) : (
336+ < Plus className = "h-4 w-4 mr-2" />
337+ ) }
338+ { workspaces . length > 0 ? 'Connect another Notion workspace' : 'Connect Notion workspace' }
339+ </ Button >
340+ </ >
341+ ) }
161342 </ div >
162343 </ Card >
163344
0 commit comments