feat: Slate & Ice Modern aesthetic redesign (#94)
* chore: update Google Fonts to Bricolage Grotesque, IBM Plex Sans, JetBrains Mono Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update Tailwind config to Slate & Ice theme colors and fonts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update CSS variables and glass-card utilities for Slate & Ice theme - Replace all color variables with Slate & Ice palette - Add glass system vars (--glass-bg, --glass-blur, --shadow-float) - Replace legacy glass-card with new variable-driven glass classes - Add breatheGlow, bellWobble, slideDown, fadeInRight keyframes - Update font references to IBM Plex Sans and Bricolage Grotesque Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: recolor BrandLogo to cyan gradient, split BrandWordmark for gradient Flow text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update TopBar with glassmorphism backdrop and cyan accent styling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update Sidebar with glassmorphism backdrop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add ambient atmosphere gradient orbs behind app shell Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update QuickStats and SessionsPanel with glass-card styling Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add WeeklyCalendar, QuickActions, OpenSessions, RecentActivity dashboard components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: redesign dashboard layout with calendar, open sessions, and glass-card panels New layout: greeting → calendar+actions → sessions+stats → activity Replaces old QuickStats and SessionsPanel with new dashboard components Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: replace remaining purple hex references with ice-cyan accent Sweep of hardcoded purple hex values (#818cf8, #6366f1) replaced with new cyan accent (#06b6d4) in QuickActions, RecentActivity, QuickLaunch, and SVG brand assets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update CLAUDE.md branding and design system for Slate & Ice Modern Updated Last Updated date, branding section (fonts, colors, glass utilities, atmosphere orbs), component styling rules, and Design System section to reflect the new ice-cyan glassmorphism theme. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add Slate & Ice Modern design doc and implementation plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: redesign login page with Slate & Ice Modern design system Apply glassmorphism styling, atmosphere orbs, branded wordmark, and consistent design tokens to match the updated app shell aesthetic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: raise TopBar z-index so profile dropdown renders above main content Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add AI assistant with in-session copilot and standalone chat with RAG Implements three-phase AI assistant feature: - Phase 0: RAG infrastructure with pgvector embeddings, Voyage AI integration, tree chunking service, and semantic search over team's flow library - Phase 1: In-session copilot panel during flow navigation with contextual AI help, current step awareness, and suggested related flows - Phase 2: Standalone AI chat page with persistent conversation history, pin/delete, and configurable retention policies (account-level) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add account management, email verification, AI fixes, and user guides - Profile settings, account transfer, delete/leave account flows - Email verification with JWT tokens and Resend integration - AI assistant/copilot fixes: markdown rendering, shared RAG helpers, token tracking, input refocus, model_validate usage - User guides hub + detail pages with 13 topic guides - Sidebar and top bar navigation for guides Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent stale chunk errors after deployments - Set Cache-Control no-cache on index.html in nginx so browsers always fetch fresh chunk references after a deploy - Auto-reload on chunk load failures (stale deploy detection) with loop prevention via sessionStorage - Show friendly "App Updated" message if auto-reload doesn't resolve it Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add email verification toggle to admin settings Adds platform-level toggle to enable/disable email verification. When disabled, the verification banner is hidden and the send endpoint returns 403. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #94.
This commit is contained in:
@@ -48,6 +48,22 @@ export const accountsApi = {
|
||||
const response = await apiClient.post<AccountInvite>(`/accounts/me/invites/${inviteId}/resend`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async transferOwnership(currentPassword: string, targetUserId: string): Promise<Account> {
|
||||
const response = await apiClient.post<Account>('/accounts/me/transfer-ownership', {
|
||||
current_password: currentPassword,
|
||||
target_user_id: targetUserId,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async leaveAccount(): Promise<void> {
|
||||
await apiClient.post('/accounts/me/leave')
|
||||
},
|
||||
|
||||
async deleteAccount(currentPassword: string): Promise<void> {
|
||||
await apiClient.delete('/accounts/me', { data: { current_password: currentPassword } })
|
||||
},
|
||||
}
|
||||
|
||||
export default accountsApi
|
||||
|
||||
59
frontend/src/api/assistantChat.ts
Normal file
59
frontend/src/api/assistantChat.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
AssistantChat,
|
||||
ChatListItem,
|
||||
ChatMessageResponse,
|
||||
RetentionSettings,
|
||||
} from '@/types/assistant-chat'
|
||||
|
||||
export const assistantChatApi = {
|
||||
async createChat(): Promise<AssistantChat> {
|
||||
const response = await apiClient.post<AssistantChat>('/assistant/chats', {})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listChats(page = 1, size = 20): Promise<ChatListItem[]> {
|
||||
const response = await apiClient.get<ChatListItem[]>('/assistant/chats', {
|
||||
params: { page, size },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getChat(chatId: string): Promise<AssistantChat> {
|
||||
const response = await apiClient.get<AssistantChat>(`/assistant/chats/${chatId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async sendMessage(chatId: string, message: string): Promise<ChatMessageResponse> {
|
||||
const response = await apiClient.post<ChatMessageResponse>(
|
||||
`/assistant/chats/${chatId}/messages`,
|
||||
{ message }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateChat(chatId: string, data: { title?: string; pinned?: boolean }): Promise<AssistantChat> {
|
||||
const response = await apiClient.patch<AssistantChat>(`/assistant/chats/${chatId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteChat(chatId: string): Promise<void> {
|
||||
await apiClient.delete(`/assistant/chats/${chatId}`)
|
||||
},
|
||||
|
||||
async bulkDeleteChats(olderThanDays: number): Promise<void> {
|
||||
await apiClient.delete('/assistant/chats', { params: { older_than_days: olderThanDays } })
|
||||
},
|
||||
|
||||
async getRetentionSettings(): Promise<RetentionSettings> {
|
||||
const response = await apiClient.get<RetentionSettings>('/assistant/retention')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateRetentionSettings(data: Partial<RetentionSettings>): Promise<RetentionSettings> {
|
||||
const response = await apiClient.patch<RetentionSettings>('/assistant/retention', data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default assistantChatApi
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Token, User, UserCreate, UserLogin } from '@/types'
|
||||
import type { Token, User, UserCreate, UserLogin, UserUpdate } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
async register(data: UserCreate): Promise<User> {
|
||||
@@ -53,6 +53,24 @@ export const authApi = {
|
||||
new_password: newPassword,
|
||||
})
|
||||
},
|
||||
|
||||
async updateProfile(data: UserUpdate): Promise<User> {
|
||||
const response = await apiClient.patch<User>('/auth/me', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getVerificationStatus(): Promise<{ enabled: boolean }> {
|
||||
const response = await apiClient.get<{ enabled: boolean }>('/auth/email/verification-status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async sendVerificationEmail(): Promise<void> {
|
||||
await apiClient.post('/auth/email/send-verification')
|
||||
},
|
||||
|
||||
async verifyEmail(token: string): Promise<void> {
|
||||
await apiClient.post('/auth/email/verify', { token })
|
||||
},
|
||||
}
|
||||
|
||||
export default authApi
|
||||
|
||||
30
frontend/src/api/copilot.ts
Normal file
30
frontend/src/api/copilot.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
CopilotStartRequest,
|
||||
CopilotStartResponse,
|
||||
CopilotMessageRequest,
|
||||
CopilotMessageResponse,
|
||||
CopilotConversation,
|
||||
} from '@/types/copilot'
|
||||
|
||||
export const copilotApi = {
|
||||
async startConversation(data: CopilotStartRequest): Promise<CopilotStartResponse> {
|
||||
const response = await apiClient.post<CopilotStartResponse>('/copilot/conversations', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async sendMessage(conversationId: string, data: CopilotMessageRequest): Promise<CopilotMessageResponse> {
|
||||
const response = await apiClient.post<CopilotMessageResponse>(
|
||||
`/copilot/conversations/${conversationId}/messages`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getConversation(conversationId: string): Promise<CopilotConversation> {
|
||||
const response = await apiClient.get<CopilotConversation>(`/copilot/conversations/${conversationId}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default copilotApi
|
||||
@@ -18,3 +18,5 @@ export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
|
||||
export { default as feedbackApi } from './feedback'
|
||||
export { default as aiBuilderApi } from './aiBuilder'
|
||||
export { default as aiChatApi } from './aiChat'
|
||||
export { copilotApi } from './copilot'
|
||||
export { assistantChatApi } from './assistantChat'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<svg viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="resolutionflow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#818cf8"/>
|
||||
<stop offset="100%" stop-color="#a78bfa"/>
|
||||
<stop offset="0%" stop-color="#06b6d4"/>
|
||||
<stop offset="100%" stop-color="#22d3ee"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Input circles (choices) -->
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg viewBox="0 0 320 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="rf-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#818cf8"/>
|
||||
<stop offset="100%" stop-color="#a78bfa"/>
|
||||
<stop offset="0%" stop-color="#06b6d4"/>
|
||||
<stop offset="100%" stop-color="#22d3ee"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</g>
|
||||
|
||||
<!-- Text -->
|
||||
<text x="72" y="50" font-family="'Plus Jakarta Sans', 'Segoe UI', sans-serif" font-size="28" font-weight="700" letter-spacing="-0.5">
|
||||
<text x="72" y="50" font-family="'Bricolage Grotesque', 'Segoe UI', sans-serif" font-size="28" font-weight="700" letter-spacing="-0.5">
|
||||
<tspan fill="#ffffff">Resolution</tspan><tspan fill="url(#rf-gradient)">Flow</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg viewBox="0 0 320 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="rf-gradient-tag" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#818cf8"/>
|
||||
<stop offset="100%" stop-color="#a78bfa"/>
|
||||
<stop offset="0%" stop-color="#06b6d4"/>
|
||||
<stop offset="100%" stop-color="#22d3ee"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
@@ -26,12 +26,12 @@
|
||||
</g>
|
||||
|
||||
<!-- Text -->
|
||||
<text x="72" y="50" font-family="'Plus Jakarta Sans', 'Segoe UI', sans-serif" font-size="28" font-weight="700" letter-spacing="-0.5">
|
||||
<text x="72" y="50" font-family="'Bricolage Grotesque', 'Segoe UI', sans-serif" font-size="28" font-weight="700" letter-spacing="-0.5">
|
||||
<tspan fill="#ffffff">Resolution</tspan><tspan fill="url(#rf-gradient-tag)">Flow</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Tagline -->
|
||||
<text x="72" y="75" font-family="'Plus Jakarta Sans', 'Segoe UI', sans-serif" font-size="13" font-weight="500" fill="url(#rf-gradient-tag)">
|
||||
<text x="72" y="75" font-family="'Bricolage Grotesque', 'Segoe UI', sans-serif" font-size="13" font-weight="500" fill="url(#rf-gradient-tag)">
|
||||
From issue to resolution, documented.
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
92
frontend/src/components/account/DeleteAccountModal.tsx
Normal file
92
frontend/src/components/account/DeleteAccountModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
interface Props {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DeleteAccountModal({ onClose }: Props) {
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const navigate = useNavigate()
|
||||
const [password, setPassword] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleDelete = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await accountsApi.deleteAccount(password)
|
||||
await logout()
|
||||
navigate('/login')
|
||||
} catch (err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
setError(axiosErr.response?.data?.detail ?? 'Failed to delete account')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="glass-card-static w-full max-w-md p-6">
|
||||
<div className="flex items-center gap-2 text-rose-500 mb-4">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold font-heading text-foreground">Delete Account</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
This action is <strong className="text-rose-400">permanent</strong>. Your account, data,
|
||||
and all associated flows will be permanently deleted.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleDelete} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-rose-500">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !password}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-semibold',
|
||||
'bg-rose-500 text-white hover:bg-rose-400 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete Forever'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
frontend/src/components/account/LeaveAccountModal.tsx
Normal file
67
frontend/src/components/account/LeaveAccountModal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface Props {
|
||||
accountName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function LeaveAccountModal({ accountName, onClose }: Props) {
|
||||
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleLeave = async () => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await accountsApi.leaveAccount()
|
||||
toast.success('You have left the account')
|
||||
await fetchUser()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
toast.error(axiosErr.response?.data?.detail ?? 'Failed to leave account')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="glass-card-static w-full max-w-md p-6">
|
||||
<div className="flex items-center gap-2 text-amber-400 mb-4">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold font-heading text-foreground">Leave Account</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Are you sure you want to leave <strong className="text-foreground">{accountName}</strong>?
|
||||
A new personal account will be created for you.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLeave}
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-semibold',
|
||||
'bg-rose-500 text-white hover:bg-rose-400 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Leave Account'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
frontend/src/components/account/TransferOwnershipModal.tsx
Normal file
115
frontend/src/components/account/TransferOwnershipModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import type { AccountMember } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface Props {
|
||||
members: AccountMember[]
|
||||
onClose: () => void
|
||||
onTransferred: () => void
|
||||
}
|
||||
|
||||
export function TransferOwnershipModal({ members, onClose, onTransferred }: Props) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const nonOwnerMembers = members.filter((m) => m.id !== user?.id)
|
||||
const [targetUserId, setTargetUserId] = useState(nonOwnerMembers[0]?.id ?? '')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!targetUserId || !password) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await accountsApi.transferOwnership(password, targetUserId)
|
||||
toast.success('Ownership transferred')
|
||||
onTransferred()
|
||||
} catch (err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
setError(axiosErr.response?.data?.detail ?? 'Transfer failed')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="glass-card-static w-full max-w-md p-6">
|
||||
<div className="flex items-center gap-2 text-amber-400 mb-4">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold font-heading text-foreground">Transfer Ownership</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
This will make the selected member the new account owner. You will become an engineer.
|
||||
</p>
|
||||
|
||||
{nonOwnerMembers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No other members to transfer to.</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">New Owner</label>
|
||||
<select
|
||||
value={targetUserId}
|
||||
onChange={(e) => setTargetUserId(e.target.value)}
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none'
|
||||
)}
|
||||
>
|
||||
{nonOwnerMembers.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name} ({m.email})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">Your Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className={cn(
|
||||
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-rose-500">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !password}
|
||||
className={cn(
|
||||
'rounded-[10px] px-4 py-2 text-sm font-semibold',
|
||||
'bg-amber-500 text-[#101114] hover:bg-amber-400',
|
||||
'disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Transfer'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
frontend/src/components/assistant/ChatMessage.tsx
Normal file
52
frontend/src/components/assistant/ChatMessage.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Sparkles, User } from 'lucide-react'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { SuggestedFlowCard } from './SuggestedFlowCard'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
|
||||
interface ChatMessageProps {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
suggestedFlows?: SuggestedFlow[]
|
||||
}
|
||||
|
||||
export function ChatMessage({ role, content, suggestedFlows }: ChatMessageProps) {
|
||||
return (
|
||||
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
role === 'assistant'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'bg-[rgba(255,255,255,0.08)] text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{role === 'assistant' ? <Sparkles size={14} /> : <User size={14} />}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={`max-w-[80%] space-y-2 ${role === 'user' ? 'text-right' : ''}`}>
|
||||
<div
|
||||
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${
|
||||
role === 'user'
|
||||
? 'bg-primary/15 text-foreground'
|
||||
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
|
||||
}`}
|
||||
>
|
||||
<MarkdownContent content={content} className="text-[0.875rem] leading-relaxed" />
|
||||
</div>
|
||||
|
||||
{/* Suggested flows (assistant only) */}
|
||||
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Related Flows
|
||||
</span>
|
||||
{suggestedFlows.map(flow => (
|
||||
<SuggestedFlowCard key={flow.tree_id} flow={flow} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/assistant/ChatSidebar.tsx
Normal file
134
frontend/src/components/assistant/ChatSidebar.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Plus, Pin, Trash2, MessageSquare } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ChatListItem } from '@/types/assistant-chat'
|
||||
|
||||
interface ChatSidebarProps {
|
||||
chats: ChatListItem[]
|
||||
activeChatId: string | null
|
||||
onSelectChat: (id: string) => void
|
||||
onNewChat: () => void
|
||||
onDeleteChat: (id: string) => void
|
||||
onTogglePin: (id: string, pinned: boolean) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
chats,
|
||||
activeChatId,
|
||||
onSelectChat,
|
||||
onNewChat,
|
||||
onDeleteChat,
|
||||
onTogglePin,
|
||||
}: ChatSidebarProps) {
|
||||
const pinnedChats = chats.filter(c => c.pinned)
|
||||
const unpinnedChats = chats.filter(c => !c.pinned)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-72 shrink-0 flex flex-col border-r h-full"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className="w-full flex items-center justify-center gap-2 bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-4 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat list */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{pinnedChats.length > 0 && (
|
||||
<div className="px-3 mb-1">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Pinned
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{pinnedChats.map(chat => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={chat.id === activeChatId}
|
||||
onSelect={() => onSelectChat(chat.id)}
|
||||
onDelete={() => onDeleteChat(chat.id)}
|
||||
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{pinnedChats.length > 0 && unpinnedChats.length > 0 && (
|
||||
<div className="mx-3 my-2 border-b" style={{ borderColor: 'var(--glass-border)' }} />
|
||||
)}
|
||||
|
||||
{unpinnedChats.map(chat => (
|
||||
<ChatItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
isActive={chat.id === activeChatId}
|
||||
onSelect={() => onSelectChat(chat.id)}
|
||||
onDelete={() => onDeleteChat(chat.id)}
|
||||
onTogglePin={() => onTogglePin(chat.id, !chat.pinned)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{chats.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground text-sm">
|
||||
No conversations yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatItem({
|
||||
chat,
|
||||
isActive,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onTogglePin,
|
||||
}: {
|
||||
chat: ChatListItem
|
||||
isActive: boolean
|
||||
onSelect: () => void
|
||||
onDelete: () => void
|
||||
onTogglePin: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-[rgba(255,255,255,0.04)] hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
||||
<div className="text-[0.6875rem] text-muted-foreground">
|
||||
{chat.message_count} messages
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onTogglePin() }}
|
||||
className="p-1 rounded hover:bg-[rgba(255,255,255,0.08)]"
|
||||
title={chat.pinned ? 'Unpin' : 'Pin'}
|
||||
>
|
||||
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete() }}
|
||||
className="p-1 rounded hover:bg-[rgba(255,255,255,0.08)] text-muted-foreground hover:text-rose-400"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
frontend/src/components/assistant/SuggestedFlowCard.tsx
Normal file
42
frontend/src/components/assistant/SuggestedFlowCard.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Box, ArrowRight } from 'lucide-react'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
|
||||
interface SuggestedFlowCardProps {
|
||||
flow: SuggestedFlow
|
||||
}
|
||||
|
||||
export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = () => {
|
||||
const path = getTreeNavigatePath(flow.tree_id, flow.tree_type)
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="w-full text-left glass-card-static p-3 rounded-xl hover:border-[rgba(255,255,255,0.12)] transition-colors group"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Box size={14} className="text-primary mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[0.8125rem] font-medium text-foreground truncate">
|
||||
{flow.tree_name}
|
||||
</span>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{flow.tree_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[0.75rem] text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{flow.relevance_snippet}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight size={14} className="text-muted-foreground group-hover:text-primary transition-colors shrink-0 mt-0.5" />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -5,42 +5,37 @@ interface BrandLogoProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ResolutionFlow brand logo icon — white monochrome.
|
||||
* sm (32x32) for header/navbar, lg (80x80) for login/register pages.
|
||||
*/
|
||||
export function BrandLogo({ size = 'sm', className }: BrandLogoProps) {
|
||||
const sizeClasses = size === 'sm' ? 'h-8 w-8' : 'h-20 w-20'
|
||||
|
||||
const strokeBase = size === 'sm' ? 1 : 2
|
||||
const strokeThick = size === 'sm' ? 1.25 : 2.5
|
||||
const dashArray = size === 'sm' ? '1 1.5' : '2 3'
|
||||
const nodeR = size === 'sm' ? { outer: 2.5, inner: 2.75 } : { outer: 5, inner: 5.5 }
|
||||
const hubR = size === 'sm' ? { glow: 5, solid: 3.5 } : { glow: 10, solid: 7 }
|
||||
|
||||
const vb = size === 'sm' ? '0 0 40 40' : '0 0 80 80'
|
||||
const s = size === 'sm' ? 1 : 2
|
||||
const gradId = size === 'sm' ? 'logoGradSm' : 'logoGradLg'
|
||||
const gradEnd = String(40 * (size === 'sm' ? 1 : 2))
|
||||
|
||||
return (
|
||||
<svg viewBox={vb} fill="none" className={cn(sizeClasses, className)}>
|
||||
{/* Input nodes */}
|
||||
<circle cx={5 * s} cy={7 * s} r={nodeR.outer} fill="white" opacity="0.35" />
|
||||
<circle cx={5 * s} cy={15 * s} r={nodeR.inner} fill="white" opacity="0.5" />
|
||||
<circle cx={5 * s} cy={25 * s} r={nodeR.inner} fill="white" opacity="0.5" />
|
||||
<circle cx={5 * s} cy={33 * s} r={nodeR.outer} fill="white" opacity="0.35" />
|
||||
|
||||
{/* Connecting lines */}
|
||||
<path d={`M${7.5 * s} ${7 * s}L${14 * s} ${17 * s}`} stroke="white" strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.45" />
|
||||
<path d={`M${7.75 * s} ${15 * s}L${14 * s} ${19 * s}`} stroke="white" strokeWidth={strokeBase} strokeLinecap="round" opacity="0.6" />
|
||||
<path d={`M${7.75 * s} ${25 * s}L${14 * s} ${21 * s}`} stroke="white" strokeWidth={strokeBase} strokeLinecap="round" opacity="0.6" />
|
||||
<path d={`M${7.5 * s} ${33 * s}L${14 * s} ${23 * s}`} stroke="white" strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.45" />
|
||||
|
||||
{/* Central hub */}
|
||||
<circle cx={18 * s} cy={20 * s} r={hubR.glow} fill="white" opacity="0.15" />
|
||||
<circle cx={18 * s} cy={20 * s} r={hubR.solid} fill="white" />
|
||||
|
||||
{/* Output arrow */}
|
||||
<path d={`M${21.5 * s} ${20 * s}H${35 * s}M${35 * s} ${20 * s}L${30 * s} ${15 * s}M${35 * s} ${20 * s}L${30 * s} ${25 * s}`} stroke="white" strokeWidth={strokeThick} strokeLinecap="round" strokeLinejoin="round" />
|
||||
<defs>
|
||||
<linearGradient id={gradId} x1="0" y1="0" x2={gradEnd} y2={gradEnd} gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#06b6d4" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx={5 * s} cy={7 * s} r={nodeR.outer} fill={`url(#${gradId})`} opacity="0.5" />
|
||||
<circle cx={5 * s} cy={15 * s} r={nodeR.inner} fill={`url(#${gradId})`} opacity="0.7" />
|
||||
<circle cx={5 * s} cy={25 * s} r={nodeR.inner} fill={`url(#${gradId})`} opacity="0.7" />
|
||||
<circle cx={5 * s} cy={33 * s} r={nodeR.outer} fill={`url(#${gradId})`} opacity="0.5" />
|
||||
<path d={`M${7.5 * s} ${7 * s}L${14 * s} ${17 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.4" />
|
||||
<path d={`M${7.75 * s} ${15 * s}L${14 * s} ${19 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" opacity="0.5" />
|
||||
<path d={`M${7.75 * s} ${25 * s}L${14 * s} ${21 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" opacity="0.5" />
|
||||
<path d={`M${7.5 * s} ${33 * s}L${14 * s} ${23 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeBase} strokeLinecap="round" strokeDasharray={dashArray} opacity="0.4" />
|
||||
<circle cx={18 * s} cy={20 * s} r={hubR.glow} fill={`url(#${gradId})`} opacity="0.15" />
|
||||
<circle cx={18 * s} cy={20 * s} r={hubR.solid} fill={`url(#${gradId})`} opacity="0.9" />
|
||||
<path d={`M${21.5 * s} ${20 * s}H${35 * s}M${35 * s} ${20 * s}L${30 * s} ${15 * s}M${35 * s} ${20 * s}L${30 * s} ${25 * s}`} stroke={`url(#${gradId})`} strokeWidth={strokeThick} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,20 +5,17 @@ interface BrandWordmarkProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ResolutionFlow wordmark — clean white text.
|
||||
* sm for header/navbar, lg for login/register pages.
|
||||
*/
|
||||
export function BrandWordmark({ size = 'sm', className }: BrandWordmarkProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'font-semibold tracking-tight text-white',
|
||||
'font-heading font-bold tracking-tight',
|
||||
size === 'sm' ? 'text-xl' : 'text-3xl',
|
||||
className
|
||||
)}
|
||||
>
|
||||
ResolutionFlow
|
||||
<span className="text-foreground">Resolution</span>
|
||||
<span className="text-gradient-brand">Flow</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRouteError, isRouteErrorResponse, useNavigate } from 'react-router-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function isChunkLoadError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
const msg = error.message.toLowerCase()
|
||||
return (
|
||||
msg.includes('failed to fetch dynamically imported module') ||
|
||||
msg.includes('importing a module script failed') ||
|
||||
msg.includes('loading chunk') ||
|
||||
msg.includes('loading css chunk')
|
||||
)
|
||||
}
|
||||
|
||||
const RELOAD_KEY = 'rf_chunk_reload'
|
||||
|
||||
export function RouteError() {
|
||||
const error = useRouteError()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Auto-reload once on chunk load failures (stale deploy)
|
||||
useEffect(() => {
|
||||
if (isChunkLoadError(error)) {
|
||||
const lastReload = sessionStorage.getItem(RELOAD_KEY)
|
||||
const now = Date.now()
|
||||
// Only auto-reload if we haven't reloaded in the last 10 seconds (prevent loops)
|
||||
if (!lastReload || now - Number(lastReload) > 10_000) {
|
||||
sessionStorage.setItem(RELOAD_KEY, String(now))
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
}, [error])
|
||||
|
||||
let errorMessage = 'An unexpected error occurred'
|
||||
let errorDetails = ''
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (isChunkLoadError(error)) {
|
||||
errorMessage = 'App Updated'
|
||||
errorDetails = 'A new version was deployed. Please refresh the page.'
|
||||
} else if (isRouteErrorResponse(error)) {
|
||||
errorMessage = error.status === 404 ? 'Page not found' : `Error ${error.status}`
|
||||
errorDetails = error.statusText || ''
|
||||
} else if (error instanceof Error) {
|
||||
|
||||
180
frontend/src/components/copilot/CopilotPanel.tsx
Normal file
180
frontend/src/components/copilot/CopilotPanel.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { X, Send, Sparkles, Loader2 } from 'lucide-react'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { copilotApi } from '@/api/copilot'
|
||||
import { SuggestedFlowCard } from '@/components/assistant/SuggestedFlowCard'
|
||||
import type { CopilotMessage, SuggestedFlow } from '@/types/copilot'
|
||||
|
||||
interface CopilotPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
treeId: string
|
||||
sessionId?: string
|
||||
currentNodeId?: string
|
||||
}
|
||||
|
||||
export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId }: CopilotPanelProps) {
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<CopilotMessage[]>([])
|
||||
const [suggestedFlows, setSuggestedFlows] = useState<SuggestedFlow[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [initializing, setInitializing] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const startConversation = useCallback(async () => {
|
||||
setInitializing(true)
|
||||
try {
|
||||
const response = await copilotApi.startConversation({
|
||||
tree_id: treeId,
|
||||
session_id: sessionId,
|
||||
current_node_id: currentNodeId,
|
||||
})
|
||||
setConversationId(response.conversation_id)
|
||||
setMessages([{ role: 'assistant', content: response.greeting }])
|
||||
} catch {
|
||||
setMessages([{ role: 'assistant', content: 'Failed to start copilot. Please try again.' }])
|
||||
} finally {
|
||||
setInitializing(false)
|
||||
}
|
||||
}, [treeId, sessionId, currentNodeId])
|
||||
|
||||
// Start conversation when panel opens or treeId changes
|
||||
useEffect(() => {
|
||||
if (isOpen && !conversationId && !initializing) {
|
||||
startConversation()
|
||||
}
|
||||
}, [isOpen, treeId, startConversation, conversationId, initializing])
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !conversationId || loading) return
|
||||
|
||||
const userMessage = input.trim()
|
||||
setInput('')
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await copilotApi.sendMessage(conversationId, {
|
||||
message: userMessage,
|
||||
current_node_id: currentNodeId,
|
||||
})
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: response.content }])
|
||||
if (response.suggested_flows.length > 0) {
|
||||
setSuggestedFlows(response.suggested_flows)
|
||||
}
|
||||
} catch {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: 'Sorry, something went wrong. Please try again.' }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-0 top-0 bottom-0 z-50 flex flex-col border-l"
|
||||
style={{
|
||||
width: '400px',
|
||||
background: 'rgba(16, 17, 20, 0.95)',
|
||||
backdropFilter: 'var(--glass-blur)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||
borderColor: 'var(--glass-border)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-primary" />
|
||||
<span className="text-sm font-semibold text-foreground">AI Copilot</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[0.8125rem] leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary/15 text-foreground'
|
||||
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
|
||||
}`}
|
||||
>
|
||||
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-xl px-3.5 py-2.5">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggested flows */}
|
||||
{suggestedFlows.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Related Flows
|
||||
</span>
|
||||
{suggestedFlows.map(flow => (
|
||||
<SuggestedFlowCard key={flow.tree_id} flow={flow} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about this step..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
disabled={loading || initializing}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || loading || initializing}
|
||||
className="bg-gradient-brand text-[#101114] p-2.5 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
frontend/src/components/copilot/CopilotToggle.tsx
Normal file
20
frontend/src/components/copilot/CopilotToggle.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { MessageCircle } from 'lucide-react'
|
||||
|
||||
interface CopilotToggleProps {
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
export function CopilotToggle({ isOpen, onToggle }: CopilotToggleProps) {
|
||||
if (isOpen) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="fixed bottom-6 right-6 z-40 bg-gradient-brand text-[#101114] p-3.5 rounded-full shadow-lg shadow-primary/30 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
title="Open AI Copilot"
|
||||
>
|
||||
<MessageCircle size={22} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
65
frontend/src/components/dashboard/OpenSessions.tsx
Normal file
65
frontend/src/components/dashboard/OpenSessions.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getTreeNavigatePath } from '@/lib/routing'
|
||||
|
||||
interface OpenSession {
|
||||
id: string
|
||||
treeName: string
|
||||
treeId: string
|
||||
treeType?: string
|
||||
stepNumber?: number
|
||||
totalSteps?: number
|
||||
timeAgo: string
|
||||
}
|
||||
|
||||
interface OpenSessionsProps {
|
||||
sessions: OpenSession[]
|
||||
}
|
||||
|
||||
export function OpenSessions({ sessions }: OpenSessionsProps) {
|
||||
return (
|
||||
<div className="glass-card-static flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">My Open Sessions</h3>
|
||||
<Link to="/sessions" className="text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors">
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">No open sessions</p>
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session, i) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center gap-3 px-5 py-3"
|
||||
style={{
|
||||
borderBottom: i < sessions.length - 1 ? '1px solid var(--glass-border)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-amber-400" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-foreground truncate">{session.treeName}</div>
|
||||
<div className="text-[0.6875rem] text-muted-foreground">
|
||||
{session.stepNumber && session.totalSteps
|
||||
? `Step ${session.stepNumber} of ${session.totalSteps}`
|
||||
: 'In progress'}
|
||||
<span className="mx-1.5 text-[hsl(var(--text-dimmed))]">·</span>
|
||||
<span className="font-label text-[0.625rem]">{session.timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={getTreeNavigatePath(session.treeId, session.treeType)}
|
||||
state={{ sessionId: session.id }}
|
||||
className="shrink-0 rounded-lg bg-gradient-brand px-3 py-1 text-[0.6875rem] font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Resume
|
||||
</Link>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
frontend/src/components/dashboard/QuickActions.tsx
Normal file
41
frontend/src/components/dashboard/QuickActions.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Play, BookOpen, UserPlus } from 'lucide-react'
|
||||
|
||||
const ACTIONS = [
|
||||
{ icon: Plus, label: 'New Flow', description: 'Create a new flow', href: '/trees/new', color: '#06b6d4' },
|
||||
{ icon: Play, label: 'Resume Session', description: 'Continue where you left off', href: '/sessions', color: '#34d399' },
|
||||
{ icon: BookOpen, label: 'Browse Library', description: 'Explore step library', href: '/step-library', color: '#fbbf24' },
|
||||
{ icon: UserPlus, label: 'Invite Team', description: 'Add team members', href: '/account', color: '#06b6d4' },
|
||||
] as const
|
||||
|
||||
export function QuickActions() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="glass-card-static flex flex-col h-full">
|
||||
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Quick Actions</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col justify-between p-3 gap-2">
|
||||
{ACTIONS.map(({ icon: Icon, label, description, href, color }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => navigate(href)}
|
||||
className="glass-card flex items-center gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ background: `${color}15` }}
|
||||
>
|
||||
<Icon size={18} style={{ color }} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{label}</div>
|
||||
<div className="text-[0.6875rem] text-muted-foreground truncate">{description}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,15 +18,15 @@ export function QuickStats({ stats }: QuickStatsProps) {
|
||||
{stats.map((stat, i) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="fade-in rounded-xl border border-border bg-card p-4 transition-colors hover:border-border/80"
|
||||
className={cn('glass-card p-4 fade-in', i === 0 && 'active-glow')}
|
||||
style={{ animationDelay: `${50 + i * 30}ms` }}
|
||||
>
|
||||
<p className="font-label text-[0.6875rem] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
<p className="font-label text-[0.625rem] font-medium uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 font-heading text-2xl font-bold tracking-tight',
|
||||
'mt-1 font-heading text-2xl font-extrabold tracking-tight',
|
||||
stat.gradient && 'text-gradient-brand',
|
||||
stat.color
|
||||
)}
|
||||
|
||||
58
frontend/src/components/dashboard/RecentActivity.tsx
Normal file
58
frontend/src/components/dashboard/RecentActivity.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { GitBranch, Play, CheckCircle, FileText, Edit } from 'lucide-react'
|
||||
|
||||
interface ActivityItem {
|
||||
id: string
|
||||
icon: LucideIcon
|
||||
iconColor: string
|
||||
iconBg: string
|
||||
description: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface RecentActivityProps {
|
||||
activities?: ActivityItem[]
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIVITIES: ActivityItem[] = [
|
||||
{ id: '1', icon: Play, iconColor: '#34d399', iconBg: 'rgba(52, 211, 153, 0.1)', description: 'Started VPN Connectivity Triage session', timestamp: '2 min ago' },
|
||||
{ id: '2', icon: CheckCircle, iconColor: '#06b6d4', iconBg: 'rgba(6, 182, 212, 0.1)', description: 'Completed M365 License Provisioning', timestamp: '15 min ago' },
|
||||
{ id: '3', icon: Edit, iconColor: '#fbbf24', iconBg: 'rgba(251, 191, 36, 0.1)', description: 'Updated Printer Troubleshooting flow', timestamp: '1 hr ago' },
|
||||
{ id: '4', icon: GitBranch, iconColor: '#06b6d4', iconBg: 'rgba(6, 182, 212, 0.1)', description: 'Created new DNS Resolution flow', timestamp: '3 hr ago' },
|
||||
{ id: '5', icon: FileText, iconColor: '#8891a0', iconBg: 'rgba(136, 145, 160, 0.1)', description: 'Exported session report #TK-4821', timestamp: 'Yesterday' },
|
||||
]
|
||||
|
||||
export function RecentActivity({ activities = DEFAULT_ACTIVITIES }: RecentActivityProps) {
|
||||
return (
|
||||
<div className="glass-card-static">
|
||||
<div className="px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">Recent Activity</h3>
|
||||
</div>
|
||||
<div>
|
||||
{activities.map((item, i) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 px-5 py-3 fade-in"
|
||||
style={{
|
||||
animationDelay: `${750 + i * 40}ms`,
|
||||
borderBottom: i < activities.length - 1 ? '1px solid var(--glass-border)' : undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px]"
|
||||
style={{ background: item.iconBg }}
|
||||
>
|
||||
<item.icon size={16} style={{ color: item.iconColor }} />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<p className="text-sm text-foreground">{item.description}</p>
|
||||
</div>
|
||||
<span className="shrink-0 font-label text-[0.625rem] text-muted-foreground pt-1">
|
||||
{item.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,8 +21,8 @@ export function SessionsPanel({ sessions, delay = 200 }: SessionsPanelProps) {
|
||||
if (sessions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fade-in rounded-xl border border-border bg-card" style={{ animationDelay: `${delay}ms` }}>
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<div className="glass-card-static fade-in" style={{ animationDelay: `${delay}ms` }}>
|
||||
<div className="flex items-center justify-between px-4 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">Recent Sessions</h3>
|
||||
<Link to="/sessions" className="text-[0.6875rem] text-muted-foreground hover:text-foreground transition-colors">
|
||||
View All
|
||||
|
||||
91
frontend/src/components/dashboard/WeeklyCalendar.tsx
Normal file
91
frontend/src/components/dashboard/WeeklyCalendar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Calendar } from 'lucide-react'
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string
|
||||
title: string
|
||||
time: string
|
||||
type: 'default' | 'maintenance'
|
||||
}
|
||||
|
||||
interface WeeklyCalendarProps {
|
||||
events?: Record<string, CalendarEvent[]>
|
||||
}
|
||||
|
||||
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
|
||||
|
||||
function getWeekDays(): { label: string; date: Date; dateStr: string; isToday: boolean }[] {
|
||||
const now = new Date()
|
||||
const day = now.getDay()
|
||||
const mondayOffset = day === 0 ? 6 : day - 1
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - mondayOffset)
|
||||
|
||||
return DAY_NAMES.map((label, i) => {
|
||||
const d = new Date(monday)
|
||||
d.setDate(monday.getDate() + i)
|
||||
const dateStr = d.toISOString().split('T')[0]
|
||||
const isToday = d.toDateString() === now.toDateString()
|
||||
return { label, date: d, dateStr, isToday }
|
||||
})
|
||||
}
|
||||
|
||||
export function WeeklyCalendar({ events = {} }: WeeklyCalendarProps) {
|
||||
const days = useMemo(() => getWeekDays(), [])
|
||||
|
||||
return (
|
||||
<div className="glass-card-static flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 px-5 py-3" style={{ borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<Calendar size={16} className="text-muted-foreground" />
|
||||
<h3 className="font-heading text-sm font-bold text-foreground">This Week</h3>
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{days.map((day, i) => {
|
||||
const dayEvents = events[day.dateStr] || []
|
||||
return (
|
||||
<div
|
||||
key={day.dateStr}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
style={{
|
||||
borderRight: i < 4 ? '1px solid var(--glass-border)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-2 py-2 text-center"
|
||||
style={{
|
||||
borderBottom: day.isToday ? '2px solid #06b6d4' : '1px solid var(--glass-border)',
|
||||
}}
|
||||
>
|
||||
<span className={`font-label text-[0.625rem] uppercase tracking-[0.1em] ${day.isToday ? 'text-cyan-400' : 'text-muted-foreground'}`}>
|
||||
{day.label}
|
||||
</span>
|
||||
<div className={`text-sm font-heading font-bold ${day.isToday ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||
{day.date.getDate()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-1.5 space-y-1">
|
||||
{dayEvents.length === 0 ? (
|
||||
<p className="text-[0.625rem] text-[hsl(var(--text-dimmed))] text-center py-2">No events</p>
|
||||
) : (
|
||||
dayEvents.map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="rounded-md px-2 py-1.5 text-[0.6875rem] cursor-pointer hover:bg-accent/30 transition-colors"
|
||||
style={{
|
||||
borderLeft: `3px solid ${event.type === 'maintenance' ? '#fbbf24' : '#06b6d4'}`,
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
<div className="font-medium text-foreground truncate">{event.title}</div>
|
||||
<div className="font-label text-[0.5625rem] text-muted-foreground">{event.time}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
frontend/src/components/guides/GuideCard.tsx
Normal file
34
frontend/src/components/guides/GuideCard.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { Guide } from '@/data/guides'
|
||||
|
||||
interface GuideCardProps {
|
||||
guide: Guide
|
||||
}
|
||||
|
||||
export function GuideCard({ guide }: GuideCardProps) {
|
||||
const Icon = guide.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/guides/${guide.slug}`}
|
||||
className="glass-card block rounded-2xl p-5 transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Icon size={20} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-heading font-semibold text-foreground mb-1">
|
||||
{guide.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{guide.summary}
|
||||
</p>
|
||||
<span className="mt-2 inline-block font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary">
|
||||
{guide.sections.length} {guide.sections.length === 1 ? 'section' : 'sections'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/guides/GuideSection.tsx
Normal file
49
frontend/src/components/guides/GuideSection.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Lightbulb } from 'lucide-react'
|
||||
import type { GuideSection as GuideSectionType } from '@/data/guides'
|
||||
|
||||
interface GuideSectionProps {
|
||||
section: GuideSectionType
|
||||
index: number
|
||||
}
|
||||
|
||||
export function GuideSection({ section, index }: GuideSectionProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-base font-heading font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-primary/10 text-xs font-bold text-primary">
|
||||
{index + 1}
|
||||
</span>
|
||||
{section.title}
|
||||
</h3>
|
||||
<ol className="space-y-3 pl-8">
|
||||
{section.steps.map((step, i) => (
|
||||
<li key={i} className="relative">
|
||||
<span className="absolute -left-6 top-0.5 font-label text-[0.625rem] text-muted-foreground">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<p
|
||||
className="text-sm text-foreground leading-relaxed"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: step.instruction
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong class="text-foreground font-semibold">$1</strong>')
|
||||
}}
|
||||
/>
|
||||
{step.detail && (
|
||||
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{step.detail}
|
||||
</p>
|
||||
)}
|
||||
{step.tip && (
|
||||
<div className="mt-2 flex items-start gap-2 rounded-lg bg-primary/5 border-l-2 border-primary px-3 py-2">
|
||||
<Lightbulb size={14} className="text-primary shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="font-semibold text-foreground">Tip:</span> {step.tip}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
import { TopBar } from './TopBar'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { EmailVerificationBanner } from './EmailVerificationBanner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function AppLayout() {
|
||||
@@ -59,7 +60,34 @@ export function AppLayout() {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={cn('app-shell', sidebarCollapsed && 'app-shell--collapsed')}>
|
||||
<>
|
||||
{/* Atmosphere orbs — ambient light behind glass */}
|
||||
<div
|
||||
className="pointer-events-none fixed z-0"
|
||||
style={{
|
||||
top: '-120px',
|
||||
right: '-80px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(6, 182, 212, 0.15) 0%, rgba(6, 182, 212, 0.04) 40%, transparent 70%)',
|
||||
filter: 'blur(60px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none fixed z-0"
|
||||
style={{
|
||||
bottom: '-100px',
|
||||
left: '-60px',
|
||||
width: '500px',
|
||||
height: '500px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, rgba(99, 102, 241, 0.02) 40%, transparent 70%)',
|
||||
filter: 'blur(50px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={cn('app-shell relative z-[1]', sidebarCollapsed && 'app-shell--collapsed')}>
|
||||
{/* Top Bar - spans full width */}
|
||||
<TopBar />
|
||||
|
||||
@@ -156,9 +184,11 @@ export function AppLayout() {
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="main-content overflow-y-auto">
|
||||
<EmailVerificationBanner />
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
57
frontend/src/components/layout/EmailVerificationBanner.tsx
Normal file
57
frontend/src/components/layout/EmailVerificationBanner.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AlertTriangle, X, Loader2 } from 'lucide-react'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export function EmailVerificationBanner() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [verificationEnabled, setVerificationEnabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getVerificationStatus()
|
||||
.then((data) => setVerificationEnabled(data.enabled))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
if (!user || user.email_verified_at || dismissed || !verificationEnabled) return null
|
||||
|
||||
const handleResend = async () => {
|
||||
setIsSending(true)
|
||||
try {
|
||||
await authApi.sendVerificationEmail()
|
||||
toast.success('Verification email sent')
|
||||
} catch {
|
||||
toast.error('Failed to send verification email')
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-amber-400/20 bg-amber-400/5 px-4 py-2 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0 text-amber-400" />
|
||||
<span className="text-amber-200">
|
||||
Your email is not verified.
|
||||
</span>
|
||||
<button
|
||||
onClick={handleResend}
|
||||
disabled={isSending}
|
||||
className={cn(
|
||||
'text-amber-400 underline hover:text-amber-300 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{isSending ? <Loader2 className="inline h-3 w-3 animate-spin" /> : 'Resend verification email'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const ACTIONS: QuickAction[] = [
|
||||
{ id: 'new-project', icon: Plus, label: 'New Project', description: 'Create a step-by-step project', path: '/flows/new', color: '#8b5cf6' },
|
||||
{ id: 'sessions', icon: Play, label: 'View Sessions', description: 'See active and recent sessions', path: '/sessions', color: '#f59e0b' },
|
||||
{ id: 'step-library', icon: Bookmark, label: 'Step Library', description: 'Browse reusable steps', path: '/step-library', color: '#10b981' },
|
||||
{ id: 'exports', icon: FileText, label: 'Exports & Shares', description: 'View shared session exports', path: '/shares', color: '#6366f1' },
|
||||
{ id: 'exports', icon: FileText, label: 'Exports & Shares', description: 'View shared session exports', path: '/shares', color: '#06b6d4' },
|
||||
{ id: 'team', icon: Users, label: 'Team Settings', description: 'Manage team members and roles', path: '/account', color: '#ec4899' },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles } from 'lucide-react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles, BotMessageSquare, BookOpen } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
@@ -63,7 +63,13 @@ export function Sidebar() {
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="sidebar flex flex-col border-r border-border bg-[hsl(var(--sidebar-bg))]"
|
||||
className="sidebar flex flex-col border-r"
|
||||
style={{
|
||||
background: 'rgba(16, 17, 20, 0.5)',
|
||||
backdropFilter: 'var(--glass-blur-light)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur-light)',
|
||||
borderColor: 'var(--glass-border)',
|
||||
}}
|
||||
onWheel={handleSidebarWheel}
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
@@ -76,8 +82,10 @@ export function Sidebar() {
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" collapsed />
|
||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||
<NavItem href="/guides" icon={BookOpen} label="User Guides" collapsed />
|
||||
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" collapsed />
|
||||
</div>
|
||||
</>
|
||||
@@ -86,7 +94,7 @@ export function Sidebar() {
|
||||
{/* Pinned Flows */}
|
||||
<PinnedFlowsSection flows={pinnedItems} onUnpin={unpinFlow} />
|
||||
|
||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||
<div style={{ borderBottom: '1px solid var(--glass-border)' }} />
|
||||
|
||||
{/* Primary Navigation */}
|
||||
<div className="px-3 py-2 space-y-0.5">
|
||||
@@ -107,6 +115,7 @@ export function Sidebar() {
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" />
|
||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||
</div>
|
||||
@@ -117,12 +126,16 @@ export function Sidebar() {
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Footer */}
|
||||
<div className={cn(
|
||||
"border-t border-[hsl(var(--border-subtle))]",
|
||||
sidebarCollapsed ? "px-1.5 py-2 flex flex-col items-center" : "px-3 py-2 space-y-0.5"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"border-t",
|
||||
sidebarCollapsed ? "px-1.5 py-2 flex flex-col items-center" : "px-3 py-2 space-y-0.5"
|
||||
)}
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
{!sidebarCollapsed && (
|
||||
<>
|
||||
<NavItem href="/guides" icon={BookOpen} label="User Guides" />
|
||||
<NavItem href="/feedback" icon={MessageSquareText} label="Feedback" />
|
||||
<NavItem href="/account" icon={Settings} label="Account" />
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Search, Zap, LogOut, Shield, Settings } from 'lucide-react'
|
||||
import { Search, Zap, LogOut, Shield, Settings, HelpCircle } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
@@ -54,15 +54,21 @@ export function TopBar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="topbar flex items-center gap-4 border-b border-border bg-background px-4">
|
||||
<header
|
||||
className="topbar relative z-10 flex items-center gap-4 border-b px-4"
|
||||
style={{
|
||||
background: 'rgba(16, 17, 20, 0.6)',
|
||||
backdropFilter: 'var(--glass-blur-strong)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur-strong)',
|
||||
borderColor: 'var(--glass-border)',
|
||||
}}
|
||||
>
|
||||
{/* Logo area */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2.5 pr-4 transition-all duration-200"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-brand">
|
||||
<BrandLogo size="sm" className="h-4 w-4" />
|
||||
</div>
|
||||
<BrandLogo size="sm" className="h-7 w-7 shrink-0" />
|
||||
<span className="text-sm font-heading font-bold tracking-tight whitespace-nowrap">
|
||||
<span className="text-foreground">Resolution</span>
|
||||
<span className="text-gradient-brand">Flow</span>
|
||||
@@ -99,13 +105,20 @@ export function TopBar() {
|
||||
>
|
||||
<Zap size={18} />
|
||||
</button>
|
||||
<Link
|
||||
to="/guides"
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-card hover:text-foreground transition-colors"
|
||||
title="User Guides"
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
</Link>
|
||||
<NotificationsPanel />
|
||||
|
||||
{/* User avatar & menu */}
|
||||
<div className="relative ml-2" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-brand text-xs font-heading font-bold text-white hover:opacity-90 transition-opacity"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-gradient-brand text-xs font-heading font-bold text-primary-foreground hover:opacity-90 transition-opacity"
|
||||
title={user?.name || user?.email || 'User'}
|
||||
>
|
||||
{initials}
|
||||
|
||||
495
frontend/src/data/guides.ts
Normal file
495
frontend/src/data/guides.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Rocket,
|
||||
Box,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
Play,
|
||||
Clock,
|
||||
Share2,
|
||||
Sparkles,
|
||||
BotMessageSquare,
|
||||
Bookmark,
|
||||
Wrench,
|
||||
Settings,
|
||||
BarChart3,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface GuideStep {
|
||||
instruction: string
|
||||
detail?: string
|
||||
tip?: string
|
||||
}
|
||||
|
||||
export interface GuideSection {
|
||||
title: string
|
||||
steps: GuideStep[]
|
||||
}
|
||||
|
||||
export interface Guide {
|
||||
slug: string
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
summary: string
|
||||
sections: GuideSection[]
|
||||
}
|
||||
|
||||
export const guides: Guide[] = [
|
||||
{
|
||||
slug: 'getting-started',
|
||||
title: 'Getting Started',
|
||||
icon: Rocket,
|
||||
summary: 'Account setup, first login, and navigating the app.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Logging In',
|
||||
steps: [
|
||||
{ instruction: 'Go to the ResolutionFlow login page and enter your email and password.' },
|
||||
{ instruction: 'Click **Sign In** to access your dashboard.' },
|
||||
{ instruction: 'If you forgot your password, click **Forgot password?** on the login page and follow the email instructions.', tip: 'Check your spam folder if you don\'t receive the reset email within a few minutes.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Navigating the App',
|
||||
steps: [
|
||||
{ instruction: 'The **sidebar** on the left contains all main navigation links: Dashboard, All Flows, Flow Editor, Sessions, Exports, and more.' },
|
||||
{ instruction: 'The **top bar** has a search bar (Ctrl+K / Cmd+K) to quickly find flows, sessions, and tags.' },
|
||||
{ instruction: 'Click the **Quick Launch** (lightning bolt icon) in the top bar to start a flow without navigating to it first.' },
|
||||
{ instruction: 'Your **user avatar** in the top-right opens a menu for Account settings, Admin Panel (if applicable), and Logout.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Understanding the Dashboard',
|
||||
steps: [
|
||||
{ instruction: 'The Dashboard shows your active sessions, recent flows, and quick stats at a glance.' },
|
||||
{ instruction: 'Click any active session card to resume where you left off.' },
|
||||
{ instruction: 'Use the **Pinned Flows** section at the top of the sidebar for quick access to your most-used flows.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'creating-flows',
|
||||
title: 'Creating Flows',
|
||||
icon: Box,
|
||||
summary: 'Create troubleshooting, procedural, and maintenance flows.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Creating a Troubleshooting Flow',
|
||||
steps: [
|
||||
{ instruction: 'Click **Flow Editor** in the sidebar, then click the **+ New Flow** button.' },
|
||||
{ instruction: 'Select **Troubleshooting** as the flow type.' },
|
||||
{ instruction: 'Enter a name and optional description for your flow.' },
|
||||
{ instruction: 'Click **Create** to open the canvas editor where you can build your decision tree.', tip: 'Choose a descriptive name like "DNS Resolution Failure" so your team can find it easily.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Creating a Procedural Flow (Project)',
|
||||
steps: [
|
||||
{ instruction: 'Click **Flow Editor** in the sidebar, then click the **+ New Flow** button.' },
|
||||
{ instruction: 'Select **Procedural** as the flow type.' },
|
||||
{ instruction: 'Enter a name and description.' },
|
||||
{ instruction: 'Click **Create** to open the procedural editor where you can add steps, intake forms, and checklists.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Creating a Maintenance Flow',
|
||||
steps: [
|
||||
{ instruction: 'Click **Flow Editor** in the sidebar, then click the **+ New Flow** button.' },
|
||||
{ instruction: 'Select **Maintenance** as the flow type.' },
|
||||
{ instruction: 'Enter a name and description.' },
|
||||
{ instruction: 'Click **Create**. Maintenance flows use the same step-based editor as procedural flows but support batch launches across multiple targets.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Managing Flow Properties',
|
||||
steps: [
|
||||
{ instruction: 'From the flow editor, click the flow name or settings area to update the name, description, category, and tags.' },
|
||||
{ instruction: 'Assign a **category** to organize flows by topic (e.g., "Networking", "Active Directory").' },
|
||||
{ instruction: 'Add **tags** for searchability (e.g., "DNS", "VPN", "Firewall").', tip: 'Tags are shared across your team. Use consistent naming so everyone can find relevant flows.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'tree-editor',
|
||||
title: 'Tree Editor (Canvas)',
|
||||
icon: GitBranch,
|
||||
summary: 'Build decision trees with nodes, options, actions, and solutions.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Understanding the Canvas',
|
||||
steps: [
|
||||
{ instruction: 'The canvas editor displays your troubleshooting flow as a visual decision tree.' },
|
||||
{ instruction: 'Each **node** represents a question, action, or solution in your troubleshooting path.' },
|
||||
{ instruction: 'Nodes are connected by **options** — the answers or choices that lead to the next step.' },
|
||||
{ instruction: 'Use the toolbar at the top to zoom, fit to screen, and access additional options.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Adding Nodes',
|
||||
steps: [
|
||||
{ instruction: 'Click the **+** button on any existing node to add a child node.' },
|
||||
{ instruction: 'Choose the node type: **Question** (asks the engineer something), **Action** (instructs them to do something), or **Solution** (the resolution).' },
|
||||
{ instruction: 'Type the node content — this is what the engineer will see during navigation.' },
|
||||
{ instruction: 'For Question nodes, add **options** (answers) that branch to different paths.', tip: 'Keep questions specific and actionable. "Is the DNS server responding to nslookup?" is better than "Check DNS".' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Editing Nodes',
|
||||
steps: [
|
||||
{ instruction: 'Click any node on the canvas to select it and open the edit panel.' },
|
||||
{ instruction: 'Update the node content, type, or options in the side panel.' },
|
||||
{ instruction: 'To delete a node, select it and click the **Delete** button or press the Delete key.' },
|
||||
{ instruction: 'Use **Undo** (Ctrl+Z) and **Redo** (Ctrl+Shift+Z) to revert changes.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Solution Nodes',
|
||||
steps: [
|
||||
{ instruction: 'Solution nodes are endpoints — they represent the resolution to the troubleshooting path.' },
|
||||
{ instruction: 'Write clear, actionable solutions with specific commands or steps the engineer should follow.' },
|
||||
{ instruction: 'You can have multiple solution nodes for different resolution paths.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'procedural-editor',
|
||||
title: 'Procedural Flow Editor',
|
||||
icon: ListChecks,
|
||||
summary: 'Build step-by-step procedures with intake forms and checklists.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Adding Steps',
|
||||
steps: [
|
||||
{ instruction: 'In the procedural editor, click **Add Step** to add a new step to your flow.' },
|
||||
{ instruction: 'Enter the step title and detailed instructions.' },
|
||||
{ instruction: 'Steps execute in order from top to bottom. Drag steps to reorder them.' },
|
||||
{ instruction: 'Use **Section Headers** to group related steps under labeled sections.', tip: 'Break long procedures into sections like "Preparation", "Execution", and "Verification".' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Intake Forms',
|
||||
steps: [
|
||||
{ instruction: 'Intake forms collect information before the procedure begins (e.g., client name, server IP).' },
|
||||
{ instruction: 'Click **Add Field** in the intake form section to add a form field.' },
|
||||
{ instruction: 'Choose the field type: **Text**, **Textarea**, **Select** (dropdown), **Number**, **URL**, or **Checkbox**.' },
|
||||
{ instruction: 'Mark fields as **Required** if they must be filled before proceeding.' },
|
||||
{ instruction: 'Field values become **variables** you can reference in step instructions using the variable name.', tip: 'Use descriptive variable names like "client_name" or "server_ip" so they\'re easy to reference in steps.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Step Options',
|
||||
steps: [
|
||||
{ instruction: 'Expand **More Options** on any step to access additional settings.' },
|
||||
{ instruction: 'Add a **URL** field to link to relevant documentation or tools.' },
|
||||
{ instruction: 'Steps can include notes fields where engineers enter observations during execution.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'running-flows',
|
||||
title: 'Running Flows',
|
||||
icon: Play,
|
||||
summary: 'Navigate troubleshooting flows and execute procedural procedures.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Running a Troubleshooting Flow',
|
||||
steps: [
|
||||
{ instruction: 'Go to **All Flows** in the sidebar and find the flow you want to run.' },
|
||||
{ instruction: 'Click the flow card, then click **Start** to begin a new session.' },
|
||||
{ instruction: 'Read each question and select the answer that matches your situation.' },
|
||||
{ instruction: 'Follow the path until you reach a **Solution** node with the resolution steps.' },
|
||||
{ instruction: 'Use the **Scratchpad** (notepad icon) to take notes during navigation.', tip: 'You can pin frequently-used flows in the sidebar for quick access.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Running a Procedural Flow',
|
||||
steps: [
|
||||
{ instruction: 'Navigate to the procedural flow and click **Start**.' },
|
||||
{ instruction: 'Fill out the **Intake Form** with required information, then click **Begin**.' },
|
||||
{ instruction: 'Work through each step in order. Mark steps as complete using the checkbox.' },
|
||||
{ instruction: 'Add notes to individual steps as you work through them.' },
|
||||
{ instruction: 'The progress bar at the top shows your completion percentage.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Using Flow Assist (AI Copilot)',
|
||||
steps: [
|
||||
{ instruction: 'While navigating any flow, click the **Flow Assist** button (sparkles icon) in the bottom-right corner.' },
|
||||
{ instruction: 'Ask questions about the current step, like "What else could cause this?" or "How do I check this?"' },
|
||||
{ instruction: 'The AI understands your current position in the flow and provides contextual answers.' },
|
||||
{ instruction: 'If the AI finds related flows in your team\'s library, they appear as **Suggested Flows** cards you can click to open.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'sessions',
|
||||
title: 'Sessions',
|
||||
icon: Clock,
|
||||
summary: 'Session history, resuming, notes, and scratchpad.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Viewing Session History',
|
||||
steps: [
|
||||
{ instruction: 'Click **Sessions** in the sidebar to see all your past and active sessions.' },
|
||||
{ instruction: 'Sessions are listed newest first. Use the filters to show only active or completed sessions.' },
|
||||
{ instruction: 'Click any session to view its full details including the path taken and notes.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Resuming a Session',
|
||||
steps: [
|
||||
{ instruction: 'Find the session in your session history or on the Dashboard.' },
|
||||
{ instruction: 'Click the session, then click **Resume** to continue where you left off.' },
|
||||
{ instruction: 'All previous decisions and notes are preserved.', tip: 'Active sessions also appear in the sidebar badge count for quick reference.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Session Notes & Scratchpad',
|
||||
steps: [
|
||||
{ instruction: 'During flow navigation, click the **Scratchpad** icon to open the note-taking panel.' },
|
||||
{ instruction: 'Type free-form notes about your troubleshooting process.' },
|
||||
{ instruction: 'Notes are saved automatically and included in session exports.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'sharing-exports',
|
||||
title: 'Sharing & Exports',
|
||||
icon: Share2,
|
||||
summary: 'Share sessions and export documentation.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Sharing a Session',
|
||||
steps: [
|
||||
{ instruction: 'Open a completed session from the session detail page.' },
|
||||
{ instruction: 'Click the **Share** button to open the sharing modal.' },
|
||||
{ instruction: 'A unique share link is generated. Click **Copy Link** to copy it to your clipboard.' },
|
||||
{ instruction: 'Share the link with team members or clients — they can view the session path and notes.' },
|
||||
{ instruction: 'Manage active share links from the **Exports** page in the sidebar.', tip: 'Share links respect your account\'s public sharing settings. Account owners can enable or disable public shares.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Exporting Sessions',
|
||||
steps: [
|
||||
{ instruction: 'From a session detail page, click the **Export** button.' },
|
||||
{ instruction: 'Choose the detail level: **Summary** (high-level overview), **Standard** (key decisions), or **Detailed** (full path with all notes).' },
|
||||
{ instruction: 'Preview the export and edit it if needed before downloading.' },
|
||||
{ instruction: 'Enable **Sensitive Data Redaction** to automatically mask passwords, IPs, and credentials in the export.', tip: 'Use the summary export for client-facing documentation and the detailed export for internal records.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Managing Shares',
|
||||
steps: [
|
||||
{ instruction: 'Click **Exports** in the sidebar to see all your shared sessions.' },
|
||||
{ instruction: 'View how many times each share link has been accessed.' },
|
||||
{ instruction: 'Revoke share links by clicking the delete icon next to any active share.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'ai-assistant',
|
||||
title: 'AI Assistant',
|
||||
icon: BotMessageSquare,
|
||||
summary: 'Standalone AI chat for IT questions and flow recommendations.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Starting a Conversation',
|
||||
steps: [
|
||||
{ instruction: 'Click **AI Assistant** in the sidebar to open the chat page.' },
|
||||
{ instruction: 'Click **Start a Conversation** or the **+ New Chat** button in the left panel.' },
|
||||
{ instruction: 'Type your question in the message box and press Enter or click the send button.' },
|
||||
{ instruction: 'The AI responds as a Senior Systems & Network Engineer with MSP expertise.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Managing Conversations',
|
||||
steps: [
|
||||
{ instruction: 'All conversations are listed in the left sidebar panel, newest first.' },
|
||||
{ instruction: 'Click any conversation to switch to it and see the full message history.' },
|
||||
{ instruction: '**Pin** important conversations by right-clicking or using the pin icon — pinned chats stay at the top.' },
|
||||
{ instruction: 'Delete conversations you no longer need by clicking the trash icon.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Suggested Flows',
|
||||
steps: [
|
||||
{ instruction: 'When you ask a question, the AI searches your team\'s flow library for relevant matches.' },
|
||||
{ instruction: 'If related flows are found, they appear as **Suggested Flow** cards below the AI response.' },
|
||||
{ instruction: 'Click a suggested flow card to navigate directly to that flow.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'ai-copilot',
|
||||
title: 'Flow Assist (AI Copilot)',
|
||||
icon: Sparkles,
|
||||
summary: 'In-session AI help while navigating flows.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Opening Flow Assist',
|
||||
steps: [
|
||||
{ instruction: 'While navigating any flow, look for the **Flow Assist** button (sparkles icon) in the bottom-right corner of the screen.' },
|
||||
{ instruction: 'Click it to open the AI assistant panel on the right side.' },
|
||||
{ instruction: 'The AI automatically knows which flow you\'re in and what step you\'re on.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Asking Questions',
|
||||
steps: [
|
||||
{ instruction: 'Type your question in the message box at the bottom of the panel.' },
|
||||
{ instruction: 'Ask things like "What else could cause this?", "How do I run this command?", or "Explain this step in more detail."' },
|
||||
{ instruction: 'The AI provides contextual answers based on your current position in the flow.' },
|
||||
{ instruction: 'Your conversation persists throughout the session — you can refer back to earlier answers.', tip: 'Flow Assist is especially useful when you encounter an unfamiliar step or need additional troubleshooting guidance.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Suggested Flows',
|
||||
steps: [
|
||||
{ instruction: 'If your question relates to other flows in your team\'s library, the AI shows **Related Flows** cards.' },
|
||||
{ instruction: 'Click a card to open that flow in a new tab.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'step-library',
|
||||
title: 'Step Library',
|
||||
icon: Bookmark,
|
||||
summary: 'Reusable steps you can import into any procedural flow.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Browsing the Step Library',
|
||||
steps: [
|
||||
{ instruction: 'Click **Step Library** in the sidebar to view all saved reusable steps.' },
|
||||
{ instruction: 'Steps are organized by category and can be searched by name or tags.' },
|
||||
{ instruction: 'Click any step to view its full details and instructions.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Saving Steps to the Library',
|
||||
steps: [
|
||||
{ instruction: 'In the procedural flow editor, click the **Save to Library** option on any step.' },
|
||||
{ instruction: 'Give the library step a name and optional category.' },
|
||||
{ instruction: 'The step is now available for reuse across all your procedural and maintenance flows.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Importing Library Steps',
|
||||
steps: [
|
||||
{ instruction: 'In the procedural flow editor, click **Import from Library** when adding a new step.' },
|
||||
{ instruction: 'Browse or search the step library for the step you want.' },
|
||||
{ instruction: 'Click **Import** to add it to your flow. The imported step is a copy — editing it won\'t affect the library version.', tip: 'Use the step library for common procedures like "Verify backup status" or "Check DNS resolution" that appear across multiple flows.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'maintenance',
|
||||
title: 'Maintenance Flows',
|
||||
icon: Wrench,
|
||||
summary: 'Batch launches, target lists, and scheduled maintenance.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Setting Up a Maintenance Flow',
|
||||
steps: [
|
||||
{ instruction: 'Create a new flow and select **Maintenance** as the type.' },
|
||||
{ instruction: 'Build your steps in the procedural editor — these are the maintenance tasks to perform on each target.' },
|
||||
{ instruction: 'The flow detail page shows maintenance-specific options including batch launches and scheduling.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Target Lists',
|
||||
steps: [
|
||||
{ instruction: 'Go to **Account** > **Target Lists** to manage your saved target lists.' },
|
||||
{ instruction: 'Create a target list with the servers, workstations, or devices you maintain.' },
|
||||
{ instruction: 'Target lists can be reused across multiple maintenance flows and batch launches.', tip: 'Organize target lists by client or site for easy batch launches (e.g., "Acme Corp - Domain Controllers").' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Batch Launching',
|
||||
steps: [
|
||||
{ instruction: 'Open a maintenance flow and click **Launch Batch**.' },
|
||||
{ instruction: 'Select a saved **Target List** or manually enter targets.' },
|
||||
{ instruction: 'Click **Launch** to create a session for each target in the list.' },
|
||||
{ instruction: 'All sessions are created immediately. Click into any target to begin executing the maintenance steps.' },
|
||||
{ instruction: 'Track progress on the **Batch Status** page showing completion status across all targets.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Scheduling',
|
||||
steps: [
|
||||
{ instruction: 'On the maintenance flow detail page, click **Schedule** to set up recurring execution.' },
|
||||
{ instruction: 'Choose a schedule (e.g., weekly, monthly) using the cron expression builder.' },
|
||||
{ instruction: 'Select the target list to use for each scheduled run.' },
|
||||
{ instruction: 'Scheduled batches are launched automatically at the configured time.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'account-settings',
|
||||
title: 'Account Settings',
|
||||
icon: Settings,
|
||||
summary: 'Team management, categories, tags, and profile settings.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Profile Settings',
|
||||
steps: [
|
||||
{ instruction: 'Click your **avatar** in the top-right corner and select **Account**.' },
|
||||
{ instruction: 'Click **Profile Settings** to update your display name, email, and password.' },
|
||||
{ instruction: 'Changes take effect immediately after saving.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Team Categories',
|
||||
steps: [
|
||||
{ instruction: 'Go to **Account** and click **Team Categories** (account owner only).' },
|
||||
{ instruction: 'Add categories to organize your team\'s flows (e.g., "Networking", "Security", "Cloud").' },
|
||||
{ instruction: 'Assign colors to categories for visual distinction in the flow library.' },
|
||||
{ instruction: 'Delete or rename categories as your team\'s needs evolve.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Chat Retention',
|
||||
steps: [
|
||||
{ instruction: 'Go to **Account** and click **Chat Retention** (account owner only).' },
|
||||
{ instruction: 'Set the **retention period** (default: 90 days) — chats older than this are automatically deleted.' },
|
||||
{ instruction: 'Set the **maximum conversation count** (default: 100) — oldest chats are deleted when the limit is exceeded.' },
|
||||
{ instruction: 'Pinned chats are never automatically deleted.', tip: 'Pin important AI conversations to preserve them regardless of retention settings.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'analytics',
|
||||
title: 'Analytics',
|
||||
icon: BarChart3,
|
||||
summary: 'Dashboard metrics, team usage, and personal stats.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Team Analytics',
|
||||
steps: [
|
||||
{ instruction: 'Click **Analytics** in the sidebar to view team-wide metrics.' },
|
||||
{ instruction: 'See total flows, active sessions, completion rates, and usage trends.' },
|
||||
{ instruction: 'Filter by date range to analyze specific periods.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Personal Analytics',
|
||||
steps: [
|
||||
{ instruction: 'From the Analytics page, click **My Stats** to see your individual metrics.' },
|
||||
{ instruction: 'Track your session count, most-used flows, and average completion time.' },
|
||||
{ instruction: 'Use personal analytics to identify areas where you spend the most troubleshooting time.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -4,35 +4,52 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* ResolutionFlow Dark Theme — Purple Gradient Accents */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 100%;
|
||||
--card: 240 10% 9.4%;
|
||||
--card-foreground: 0 0% 100%;
|
||||
--popover: 240 10% 9.4%;
|
||||
--popover-foreground: 0 0% 100%;
|
||||
--primary: 243 75% 59%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 5.9% 15%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 240 5.9% 15%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 5.9% 15%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 240 5.9% 15%;
|
||||
--input: 240 5.9% 15%;
|
||||
--ring: 243 75% 59%;
|
||||
/* ResolutionFlow Dark Theme — Slate & Ice Modern */
|
||||
--background: 228 12% 7%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 220 10% 10%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 220 10% 10%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 187 72% 43%;
|
||||
--primary-foreground: 228 12% 7%;
|
||||
--secondary: 220 8% 14%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 220 8% 14%;
|
||||
--muted-foreground: 215 10% 58%;
|
||||
--accent: 220 8% 14%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 350 81% 55%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 220 8% 14%;
|
||||
--input: 220 8% 14%;
|
||||
--ring: 187 72% 43%;
|
||||
--radius: 0.75rem;
|
||||
|
||||
/* App Shell tokens */
|
||||
--sidebar-w: 260px;
|
||||
--sidebar-bg: 240 10% 4.5%;
|
||||
--sidebar-hover: 240 6% 12%;
|
||||
--sidebar-active: 243 75% 59% / 0.08;
|
||||
--border-subtle: 240 6% 12%;
|
||||
--text-dimmed: 240 4% 24%;
|
||||
--sidebar-bg: 228 12% 6%;
|
||||
--sidebar-hover: 220 8% 14%;
|
||||
--sidebar-active: 187 72% 43% / 0.10;
|
||||
--border-subtle: 220 8% 12%;
|
||||
--text-dimmed: 218 10% 39%;
|
||||
|
||||
/* Glass system */
|
||||
--glass-bg: rgba(24, 26, 31, 0.55);
|
||||
--glass-bg-hover: rgba(24, 26, 31, 0.7);
|
||||
--glass-border: rgba(255, 255, 255, 0.06);
|
||||
--glass-border-hover: rgba(255, 255, 255, 0.12);
|
||||
--glass-blur: blur(16px);
|
||||
--glass-blur-strong: blur(20px);
|
||||
--glass-blur-light: blur(12px);
|
||||
|
||||
/* Shadow system */
|
||||
--shadow-float: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
--shadow-float-hover: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
--shadow-cyan-glow: 0 8px 32px rgba(6, 182, 212, 0.08);
|
||||
|
||||
/* Easing */
|
||||
--ease-out-smooth: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,11 +76,11 @@
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
@@ -140,6 +157,30 @@
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from { transform: translateY(-100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeInRight {
|
||||
from { transform: translateX(30px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes breatheGlow {
|
||||
from { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 20px rgba(6, 182, 212, 0.04); }
|
||||
to { box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 30px rgba(6, 182, 212, 0.12); }
|
||||
}
|
||||
|
||||
@keyframes bellWobble {
|
||||
0% { transform: rotate(0deg); }
|
||||
20% { transform: rotate(8deg); }
|
||||
40% { transform: rotate(-6deg); }
|
||||
60% { transform: rotate(4deg); }
|
||||
80% { transform: rotate(-2deg); }
|
||||
100% { transform: rotate(0deg); }
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-fade-in {
|
||||
animation: fade-in 200ms ease-out;
|
||||
@@ -171,30 +212,37 @@
|
||||
@apply bg-gradient-brand bg-clip-text text-transparent;
|
||||
}
|
||||
|
||||
/* ── Legacy glass-card utilities (preserved for backward compatibility) ── */
|
||||
/* New components should use bg-card border-border rounded-xl instead */
|
||||
|
||||
/* Glass card — interactive with hover lift */
|
||||
.glass-card {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.01) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-float);
|
||||
transition: transform 200ms var(--ease-out-smooth),
|
||||
border-color 200ms var(--ease-out-smooth),
|
||||
box-shadow 200ms var(--ease-out-smooth);
|
||||
}
|
||||
.glass-card:hover {
|
||||
transform: scale(1.02);
|
||||
border-color: var(--glass-border-hover);
|
||||
box-shadow: var(--shadow-float-hover);
|
||||
}
|
||||
|
||||
.glass-card-hover {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.06) 0%, rgba(255,255,255,0.02) 100%);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
/* Glass card — static, no hover transform */
|
||||
.glass-card-static {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-float);
|
||||
}
|
||||
|
||||
.glass-card-glow {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 0 40px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.glass-stat {
|
||||
background: rgba(20, 20, 25, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(10px);
|
||||
/* Breathing glow for highlighted stat cards */
|
||||
.active-glow {
|
||||
animation: breatheGlow 3s ease-in-out infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,11 +253,11 @@
|
||||
border: 1px solid hsl(var(--border)) !important;
|
||||
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.3) !important;
|
||||
border-radius: 0.75rem;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
[data-sonner-toast] [data-title] {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText } from 'lucide-react'
|
||||
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock } from 'lucide-react'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import type { Account, AccountMember, AccountInvite } from '@/types'
|
||||
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
|
||||
import { LeaveAccountModal } from '@/components/account/LeaveAccountModal'
|
||||
import { DeleteAccountModal } from '@/components/account/DeleteAccountModal'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
@@ -29,6 +32,11 @@ export function AccountSettingsPage() {
|
||||
const [editedName, setEditedName] = useState('')
|
||||
const [isSavingName, setIsSavingName] = useState(false)
|
||||
|
||||
// Modals
|
||||
const [showTransferModal, setShowTransferModal] = useState(false)
|
||||
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
|
||||
// Invite form
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteRole, setInviteRole] = useState('engineer')
|
||||
@@ -341,16 +349,31 @@ export function AccountSettingsPage() {
|
||||
<p className="text-xs text-muted-foreground">{member.email}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
member.account_role === 'owner' && 'bg-accent text-foreground',
|
||||
member.account_role === 'engineer' && 'bg-accent text-muted-foreground',
|
||||
member.account_role === 'viewer' && 'bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{member.account_role}
|
||||
</span>
|
||||
{member.account_role === 'owner' ? (
|
||||
<span className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-accent text-foreground">
|
||||
owner
|
||||
</span>
|
||||
) : (
|
||||
<select
|
||||
value={member.account_role}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
const updated = await accountsApi.updateMemberRole(member.id, e.target.value)
|
||||
setMembers(members.map((m) => m.id === member.id ? { ...m, account_role: updated.account_role } : m))
|
||||
toast.success(`Role updated to ${updated.account_role}`)
|
||||
} catch {
|
||||
toast.error('Failed to update role')
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border border-border bg-card px-2 py-0.5 text-xs',
|
||||
'text-foreground focus:border-primary focus:outline-none'
|
||||
)}
|
||||
>
|
||||
<option value="engineer">engineer</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
)}
|
||||
{!member.is_active && (
|
||||
<span className="rounded-full bg-red-400/10 px-2 py-0.5 text-xs text-red-400">
|
||||
Inactive
|
||||
@@ -478,6 +501,21 @@ export function AccountSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Settings Link */}
|
||||
<Link
|
||||
to="/account/profile"
|
||||
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCog className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Profile Settings</h2>
|
||||
<p className="text-sm text-muted-foreground">Update your name, email, and personal details</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground group-hover:text-foreground transition-colors">→</span>
|
||||
</Link>
|
||||
|
||||
{/* Team Categories Link (owners only) */}
|
||||
{isAccountOwner && (
|
||||
<Link
|
||||
@@ -512,6 +550,23 @@ export function AccountSettingsPage() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Chat Retention Link (owners only) */}
|
||||
{isAccountOwner && (
|
||||
<Link
|
||||
to="/account/chat-retention"
|
||||
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Chat Retention</h2>
|
||||
<p className="text-sm text-muted-foreground">Configure AI assistant conversation retention policies</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground group-hover:text-foreground transition-colors">→</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Feedback Link (all users) */}
|
||||
<Link
|
||||
to="/feedback"
|
||||
@@ -563,7 +618,85 @@ export function AccountSettingsPage() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* Danger Zone */}
|
||||
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="h-5 w-5 text-rose-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{isAccountOwner ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Transfer Ownership</p>
|
||||
<p className="text-xs text-muted-foreground">Make another member the account owner</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowTransferModal(true)}
|
||||
className={cn(
|
||||
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||
'border border-amber-500/30 text-amber-400 hover:bg-amber-500/10'
|
||||
)}
|
||||
>
|
||||
Transfer
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border pt-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Delete Account</p>
|
||||
<p className="text-xs text-muted-foreground">Permanently delete your account and all data</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className={cn(
|
||||
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||
'border border-rose-500/30 text-rose-400 hover:bg-rose-500/10'
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Leave Account</p>
|
||||
<p className="text-xs text-muted-foreground">Leave this account and create a personal one</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
className={cn(
|
||||
'rounded-[10px] px-3 py-1.5 text-sm font-medium',
|
||||
'border border-rose-500/30 text-rose-400 hover:bg-rose-500/10'
|
||||
)}
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showTransferModal && (
|
||||
<TransferOwnershipModal
|
||||
members={members}
|
||||
onClose={() => setShowTransferModal(false)}
|
||||
onTransferred={() => { setShowTransferModal(false); loadData() }}
|
||||
/>
|
||||
)}
|
||||
{showLeaveModal && account && (
|
||||
<LeaveAccountModal
|
||||
accountName={account.name}
|
||||
onClose={() => setShowLeaveModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showDeleteModal && (
|
||||
<DeleteAccountModal onClose={() => setShowDeleteModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
230
frontend/src/pages/AssistantChatPage.tsx
Normal file
230
frontend/src/pages/AssistantChatPage.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Sparkles, Send, Loader2 } from 'lucide-react'
|
||||
import { assistantChatApi } from '@/api/assistantChat'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
|
||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||
import type { ChatListItem, AssistantChatMessage as ChatMessageType } from '@/types/assistant-chat'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
|
||||
interface MessageWithMeta extends ChatMessageType {
|
||||
suggestedFlows?: SuggestedFlow[]
|
||||
}
|
||||
|
||||
export default function AssistantChatPage() {
|
||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Load chat list
|
||||
useEffect(() => {
|
||||
loadChats()
|
||||
}, [])
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
const loadChats = async () => {
|
||||
try {
|
||||
const list = await assistantChatApi.listChats(1, 100)
|
||||
setChats(list)
|
||||
} catch {
|
||||
// silently handle
|
||||
}
|
||||
}
|
||||
|
||||
const selectChat = useCallback(async (chatId: string) => {
|
||||
setActiveChatId(chatId)
|
||||
try {
|
||||
const chat = await assistantChatApi.getChat(chatId)
|
||||
setMessages(chat.messages.map(m => ({ ...m })))
|
||||
} catch {
|
||||
setMessages([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const chat = await assistantChatApi.createChat()
|
||||
setChats(prev => [
|
||||
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
|
||||
...prev,
|
||||
])
|
||||
setActiveChatId(chat.id)
|
||||
setMessages([])
|
||||
} catch {
|
||||
toast.error('Failed to create chat')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteChat = async (chatId: string) => {
|
||||
try {
|
||||
await assistantChatApi.deleteChat(chatId)
|
||||
setChats(prev => prev.filter(c => c.id !== chatId))
|
||||
if (activeChatId === chatId) {
|
||||
setActiveChatId(null)
|
||||
setMessages([])
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete chat')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTogglePin = async (chatId: string, pinned: boolean) => {
|
||||
try {
|
||||
await assistantChatApi.updateChat(chatId, { pinned })
|
||||
setChats(prev =>
|
||||
prev.map(c => c.id === chatId ? { ...c, pinned } : c)
|
||||
)
|
||||
} catch {
|
||||
toast.error('Failed to update chat')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !activeChatId || loading) return
|
||||
|
||||
const userMessage = input.trim()
|
||||
setInput('')
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await assistantChatApi.sendMessage(activeChatId, userMessage)
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
||||
])
|
||||
// Update chat list title if it was the first message
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === activeChatId
|
||||
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: 'Sorry, something went wrong. Please try again.' },
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
{/* Sidebar */}
|
||||
<ChatSidebar
|
||||
chats={chats}
|
||||
activeChatId={activeChatId}
|
||||
onSelectChat={selectChat}
|
||||
onNewChat={handleNewChat}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onTogglePin={handleTogglePin}
|
||||
/>
|
||||
|
||||
{/* Main chat area */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{activeChatId ? (
|
||||
<>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Sparkles size={28} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">
|
||||
AI Assistant
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
Ask me anything about IT infrastructure, networking, Active Directory,
|
||||
cloud platforms, or troubleshooting. I'll also suggest relevant flows from your team's library.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
suggestedFlows={msg.suggestedFlows}
|
||||
/>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-primary" />
|
||||
</div>
|
||||
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-2xl px-4 py-3">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-6 py-4 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-end gap-3 max-w-3xl mx-auto">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about IT, networking, troubleshooting..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || loading}
|
||||
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-20 h-20 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Sparkles size={32} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-heading font-semibold text-foreground mb-2">
|
||||
AI Assistant
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
Your Senior Systems & Network Engineer. Ask anything about IT infrastructure,
|
||||
or start a new chat to get personalized help with your team's flows.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleNewChat}
|
||||
className="bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-6 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Start a Conversation
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
frontend/src/pages/GuideDetailPage.tsx
Normal file
78
frontend/src/pages/GuideDetailPage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { ChevronRight, ArrowLeft } from 'lucide-react'
|
||||
import { guides } from '@/data/guides'
|
||||
import { GuideSection } from '@/components/guides/GuideSection'
|
||||
|
||||
export default function GuideDetailPage() {
|
||||
const { slug } = useParams<{ slug: string }>()
|
||||
const guide = guides.find(g => g.slug === slug)
|
||||
|
||||
if (!guide) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground mb-2">Guide Not Found</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">The guide you're looking for doesn't exist.</p>
|
||||
<Link
|
||||
to="/guides"
|
||||
className="bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-5 py-2 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Back to Guides
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Icon = guide.icon
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-3xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground mb-6">
|
||||
<Link to="/guides" className="hover:text-primary transition-colors">
|
||||
User Guides
|
||||
</Link>
|
||||
<ChevronRight size={12} />
|
||||
<span className="text-foreground">{guide.title}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="glass-card-static rounded-2xl p-6 mb-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Icon size={20} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-heading font-bold text-foreground">{guide.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{guide.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-4 pt-4 border-t" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{guide.sections.length} {guide.sections.length === 1 ? 'section' : 'sections'}
|
||||
</span>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{guide.sections.reduce((acc, s) => acc + s.steps.length, 0)} steps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="glass-card-static rounded-2xl p-6">
|
||||
{guide.sections.map((section, i) => (
|
||||
<GuideSection key={i} section={section} index={i} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/guides"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Back to all guides
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
frontend/src/pages/GuidesHubPage.tsx
Normal file
29
frontend/src/pages/GuidesHubPage.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { BookOpen } from 'lucide-react'
|
||||
import { guides } from '@/data/guides'
|
||||
import { GuideCard } from '@/components/guides/GuideCard'
|
||||
|
||||
export default function GuidesHubPage() {
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
|
||||
<BookOpen size={20} className="text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-heading font-bold text-foreground">User Guides</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-[52px]">
|
||||
Learn how to use ResolutionFlow with step-by-step instructions for every feature.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Guide cards grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{guides.map(guide => (
|
||||
<GuideCard key={guide.slug} guide={guide} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,38 +40,59 @@ export function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||
{/* Subtle radial overlay */}
|
||||
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(100,100,120,0.03),transparent_50%)]" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
{/* Atmosphere orbs */}
|
||||
<div
|
||||
className="pointer-events-none fixed z-0"
|
||||
style={{
|
||||
top: '-120px',
|
||||
right: '-80px',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(6, 182, 212, 0.15) 0%, rgba(6, 182, 212, 0.04) 40%, transparent 70%)',
|
||||
filter: 'blur(60px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none fixed z-0"
|
||||
style={{
|
||||
bottom: '-100px',
|
||||
left: '-60px',
|
||||
width: '500px',
|
||||
height: '500px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.08) 0%, rgba(139, 92, 246, 0.02) 40%, transparent 70%)',
|
||||
filter: 'blur(60px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative w-full max-w-md space-y-8">
|
||||
<div className="relative z-10 w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center sm:mb-6">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white flex items-center justify-center sm:w-20 sm:h-20">
|
||||
<BrandLogo size="lg" className="h-10 w-10 invert sm:h-12 sm:w-12" />
|
||||
</div>
|
||||
<BrandLogo size="lg" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold font-heading text-foreground tracking-tight">
|
||||
ResolutionFlow
|
||||
<span>Resolution</span><span className="text-gradient-brand">Flow</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-base font-medium text-muted-foreground sm:mt-3 sm:text-lg">
|
||||
Decision Tree Platform
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground sm:mt-2">
|
||||
<p className="mt-1 text-sm text-muted-foreground/70 sm:mt-2">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||
<div className="bg-card border border-border rounded-xl p-6 space-y-4">
|
||||
<div className="glass-card-static p-6 space-y-4">
|
||||
{(error || localError) && (
|
||||
<div className="rounded-xl border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
|
||||
<div className="rounded-[10px] border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-400">
|
||||
{localError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-medium text-foreground">
|
||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
@@ -83,9 +104,9 @@ export function LoginPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={cn(
|
||||
'block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'block w-full rounded-[10px] border border-border bg-card px-3 py-2.5',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'transition-colors'
|
||||
)}
|
||||
placeholder="you@example.com"
|
||||
@@ -93,7 +114,7 @@ export function LoginPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-medium text-foreground">
|
||||
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-foreground">
|
||||
Password
|
||||
</label>
|
||||
<PasswordInput
|
||||
@@ -104,9 +125,9 @@ export function LoginPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={cn(
|
||||
'block w-full rounded-xl border border-border bg-card px-3 py-2',
|
||||
'block w-full rounded-[10px] border border-border bg-card px-3 py-2.5',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20',
|
||||
'transition-colors'
|
||||
)}
|
||||
placeholder="••••••••••"
|
||||
@@ -123,9 +144,9 @@ export function LoginPage() {
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'w-full rounded-xl px-4 py-2.5 text-sm font-semibold btn-press',
|
||||
'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-black',
|
||||
'w-full rounded-[10px] px-4 py-2.5 text-sm font-semibold',
|
||||
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-2 focus:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'transition-all'
|
||||
)}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { MaintenanceContextStrip } from '@/components/maintenance/MaintenanceCon
|
||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
|
||||
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
||||
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
||||
|
||||
interface StepState {
|
||||
notes: string
|
||||
@@ -84,6 +86,7 @@ export function ProceduralNavigationPage() {
|
||||
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
|
||||
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
|
||||
const [isSavingStep, setIsSavingStep] = useState(false)
|
||||
const [copilotOpen, setCopilotOpen] = useState(false)
|
||||
|
||||
// Get procedural steps from tree
|
||||
const getSteps = (): ProceduralStep[] => {
|
||||
@@ -704,6 +707,20 @@ export function ProceduralNavigationPage() {
|
||||
isSaving={isSavingStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Copilot */}
|
||||
{treeId && (
|
||||
<>
|
||||
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||
<CopilotPanel
|
||||
isOpen={copilotOpen}
|
||||
onClose={() => setCopilotOpen(false)}
|
||||
treeId={treeId}
|
||||
sessionId={session?.id}
|
||||
currentNodeId={runtimeSteps[currentStepIndex]?.id}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePaginationParams } from '@/hooks/usePaginationParams'
|
||||
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
||||
import { QuickStats } from '@/components/dashboard/QuickStats'
|
||||
import { SessionsPanel } from '@/components/dashboard/SessionsPanel'
|
||||
// QuickStats and SessionsPanel replaced by new dashboard panels
|
||||
import { TreeGridView } from '@/components/library/TreeGridView'
|
||||
import { TreeListView } from '@/components/library/TreeListView'
|
||||
import { TreeTableView } from '@/components/library/TreeTableView'
|
||||
@@ -22,6 +21,10 @@ import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { WeeklyCalendar } from '@/components/dashboard/WeeklyCalendar'
|
||||
import { QuickActions } from '@/components/dashboard/QuickActions'
|
||||
import { OpenSessions } from '@/components/dashboard/OpenSessions'
|
||||
import { RecentActivity } from '@/components/dashboard/RecentActivity'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
@@ -215,15 +218,21 @@ export function QuickStartPage() {
|
||||
const now = new Date()
|
||||
return d.toDateString() === now.toDateString()
|
||||
}).length
|
||||
const completedSessions = allSessions.filter(s => s.completed_at).length
|
||||
// completedSessions removed — no longer displayed in new layout
|
||||
|
||||
const recentSessionItems = allSessions.slice(0, 5).map(s => ({
|
||||
id: s.id,
|
||||
treeName: s.tree_snapshot?.name || 'Unknown',
|
||||
status: (s.completed_at ? 'completed' : 'in_progress') as 'completed' | 'in_progress',
|
||||
ticketNumber: s.ticket_number || undefined,
|
||||
timeAgo: timeAgo(s.started_at),
|
||||
}))
|
||||
// Open sessions for the new panel (3 oldest)
|
||||
const openSessionItems = activeSessions
|
||||
.sort((a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime())
|
||||
.slice(0, 3)
|
||||
.map(s => ({
|
||||
id: s.id,
|
||||
treeName: s.tree_snapshot?.name || 'Unknown',
|
||||
treeId: s.tree_id,
|
||||
treeType: (s.tree_snapshot as unknown as Record<string, unknown>)?.tree_type as string | undefined,
|
||||
timeAgo: timeAgo(s.started_at),
|
||||
}))
|
||||
|
||||
// recentSessionItems removed — replaced by RecentActivity component
|
||||
|
||||
// Favorites display
|
||||
const MAX_VISIBLE_FAVORITES = 8
|
||||
@@ -270,297 +279,329 @@ export function QuickStartPage() {
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="font-heading text-[1.375rem] font-bold tracking-tight text-foreground">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Welcome back. Here's what's happening with your flows.
|
||||
</p>
|
||||
{/* Greeting */}
|
||||
<div className="fade-in" style={{ animationDelay: '100ms' }}>
|
||||
<h1 className="font-heading text-4xl font-extrabold tracking-tight text-foreground">
|
||||
Good {new Date().getHours() < 12 ? 'morning' : new Date().getHours() < 18 ? 'afternoon' : 'evening'}, {user?.name?.split(' ')[0] || 'there'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row 1: Calendar + Quick Actions */}
|
||||
<div className="flex gap-4" style={{ alignItems: 'stretch' }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<WeeklyCalendar />
|
||||
</div>
|
||||
<div className="w-72 shrink-0">
|
||||
<QuickActions />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<QuickStats
|
||||
stats={[
|
||||
{ label: 'My Flows', value: myFlows.length, gradient: true },
|
||||
{ label: 'Sessions Today', value: todaySessions, color: '#f59e0b' },
|
||||
{ label: 'Open Sessions', value: openSessions, meta: `${completedSessions} completed` },
|
||||
{ label: 'Favorites', value: pinnedItems.length },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Search */}
|
||||
<div ref={searchRef} className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => query.length >= 2 && setShowResults(true)}
|
||||
placeholder="Search flows, sessions, tags…"
|
||||
className="w-full rounded-lg border border-border bg-card py-2.5 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
{showResults && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
{/* Row 2: Open Sessions + Stats 2x2 */}
|
||||
<div className="flex gap-4" style={{ alignItems: 'stretch' }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<OpenSessions sessions={openSessionItems} />
|
||||
</div>
|
||||
<div className="w-72 shrink-0">
|
||||
<div className="grid grid-cols-2 gap-3 h-full">
|
||||
{[
|
||||
{ label: 'Active Flows', value: myFlows.length, gradient: true, glow: true },
|
||||
{ label: 'This Week', value: todaySessions },
|
||||
{ label: 'Open Sessions', value: openSessions },
|
||||
{ label: 'Favorites', value: pinnedItems.length },
|
||||
].map((stat, i) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className={cn('glass-card p-4 flex flex-col justify-between fade-in', stat.glow && 'active-glow')}
|
||||
style={{ animationDelay: `${500 + i * 70}ms` }}
|
||||
>
|
||||
<p className="font-label text-[0.625rem] font-medium uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className={cn('font-heading text-2xl font-extrabold tracking-tight', stat.gradient && 'text-gradient-brand')}>
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">No results found</div>
|
||||
) : (
|
||||
<ul className="max-h-72 overflow-y-auto py-1">
|
||||
{searchResults.map((tree) => (
|
||||
<li key={tree.id}>
|
||||
<button
|
||||
onClick={() => navigate(getTreeNavigatePath(tree.id, tree.tree_type))}
|
||||
className="w-full px-4 py-3 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="text-sm font-medium text-foreground">{tree.name}</div>
|
||||
{tree.description && (
|
||||
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{tree.description}</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Sessions */}
|
||||
<SessionsPanel sessions={recentSessionItems} delay={150} />
|
||||
{/* Row 3: Recent Activity */}
|
||||
<RecentActivity />
|
||||
|
||||
{/* Favorites Section */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">
|
||||
Favorites
|
||||
{pinnedItems.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">({pinnedItems.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{hasMoreFavorites && (
|
||||
<button
|
||||
onClick={() => setShowAllFavorites(!showAllFavorites)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showAllFavorites ? 'Show less' : 'View all favorites'}
|
||||
</button>
|
||||
{/* ── Existing content below ── */}
|
||||
<div style={{ borderTop: '1px solid var(--glass-border)' }} className="pt-6 space-y-6">
|
||||
|
||||
{/* Search */}
|
||||
<div ref={searchRef} className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => query.length >= 2 && setShowResults(true)}
|
||||
placeholder="Search flows, sessions, tags…"
|
||||
className="w-full rounded-lg border border-border bg-card py-2.5 pl-9 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
{showResults && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-xl overflow-hidden">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-muted-foreground">No results found</div>
|
||||
) : (
|
||||
<ul className="max-h-72 overflow-y-auto py-1">
|
||||
{searchResults.map((tree) => (
|
||||
<li key={tree.id}>
|
||||
<button
|
||||
onClick={() => navigate(getTreeNavigatePath(tree.id, tree.tree_type))}
|
||||
className="w-full px-4 py-3 text-left transition-colors hover:bg-accent"
|
||||
>
|
||||
<div className="text-sm font-medium text-foreground">{tree.name}</div>
|
||||
{tree.description && (
|
||||
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">{tree.description}</div>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{pinnedIsLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-20 rounded-xl bg-card border border-border animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : pinnedItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
Star a flow to pin it here for quick access.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{visibleFavorites.map((flow) => (
|
||||
<button
|
||||
key={flow.tree_id}
|
||||
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
|
||||
className="group relative flex items-center gap-3 rounded-xl bg-card border border-border p-4 text-left transition-colors hover:border-border/80 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-lg shrink-0">
|
||||
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-foreground">{flow.tree_name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
togglePin(flow.tree_id)
|
||||
}}
|
||||
aria-label="Remove from favorites"
|
||||
className="absolute top-2 right-2 rounded-md p-1 text-amber-400 opacity-0 group-hover:opacity-100 hover:text-amber-300 transition-all"
|
||||
>
|
||||
<Star size={14} fill="currentColor" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My Flows Section — tabbed */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-1 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => { setActiveTab(tab.id); setPage(1) }}
|
||||
className={cn(
|
||||
'px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
{/* Favorites Section */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="font-heading text-lg font-semibold text-foreground">
|
||||
Favorites
|
||||
{pinnedItems.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">({pinnedItems.length})</span>
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2 pb-1.5">
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
/>
|
||||
</h2>
|
||||
{hasMoreFavorites && (
|
||||
<button
|
||||
onClick={() => setShowAllFavorites(!showAllFavorites)}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showAllFavorites ? 'Show less' : 'View all favorites'}
|
||||
</button>
|
||||
)}
|
||||
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
|
||||
</div>
|
||||
{pinnedIsLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-20 rounded-xl bg-card border border-border animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : pinnedItems.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
Star a flow to pin it here for quick access.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{visibleFavorites.map((flow) => (
|
||||
<button
|
||||
key={flow.tree_id}
|
||||
onClick={() => navigate(getTreeNavigatePath(flow.tree_id, flow.tree_type))}
|
||||
className="group relative flex items-center gap-3 rounded-xl bg-card border border-border p-4 text-left transition-colors hover:border-border/80 hover:bg-accent/50"
|
||||
>
|
||||
<span className="text-lg shrink-0">
|
||||
{flow.tree_type === 'procedural' ? '📋' : flow.tree_type === 'maintenance' ? '🛠️' : '🔧'}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-foreground">{flow.tree_name}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
togglePin(flow.tree_id)
|
||||
}}
|
||||
aria-label="Remove from favorites"
|
||||
className="absolute top-2 right-2 rounded-md p-1 text-amber-400 opacity-0 group-hover:opacity-100 hover:text-amber-300 transition-all"
|
||||
>
|
||||
<Star size={14} fill="currentColor" />
|
||||
</button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingFlows ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 rounded-xl bg-card border border-border animate-pulse" />
|
||||
{/* My Flows Section — tabbed */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-1 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => { setActiveTab(tab.id); setPage(1) }}
|
||||
className={cn(
|
||||
'px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="ml-auto flex items-center gap-2 pb-1.5">
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
/>
|
||||
)}
|
||||
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
|
||||
</div>
|
||||
</div>
|
||||
) : myFlows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{activeTab === 'mine'
|
||||
? "You haven't created any flows yet."
|
||||
: activeTab === 'team'
|
||||
? 'No team flows found.'
|
||||
: activeTab === 'public'
|
||||
? 'No public flows found.'
|
||||
: 'No flows found.'}
|
||||
</p>
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
label="Create your first flow"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allFlowsCeiling && (
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Showing first 500 flows. Use search or filters to find specific flows.
|
||||
|
||||
{isLoadingFlows ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 rounded-xl bg-card border border-border animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : myFlows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{activeTab === 'mine'
|
||||
? "You haven't created any flows yet."
|
||||
: activeTab === 'team'
|
||||
? 'No team flows found.'
|
||||
: activeTab === 'public'
|
||||
? 'No public flows found.'
|
||||
: 'No flows found.'}
|
||||
</p>
|
||||
)}
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
label="Create your first flow"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allFlowsCeiling && (
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
Showing first 500 flows. Use search or filters to find specific flows.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{dashboardMyFlowsView === 'grid' && (
|
||||
<TreeGridView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{dashboardMyFlowsView === 'list' && (
|
||||
<TreeListView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{dashboardMyFlowsView === 'table' && (
|
||||
<TreeTableView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{dashboardMyFlowsView === 'grid' && (
|
||||
<TreeGridView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{dashboardMyFlowsView === 'list' && (
|
||||
<TreeListView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
{dashboardMyFlowsView === 'table' && (
|
||||
<TreeTableView
|
||||
trees={myFlows}
|
||||
onStartSession={handleStartSession}
|
||||
onTagClick={handleTagClick}
|
||||
onFolderCreated={handleFolderCreated}
|
||||
onDeleteTree={handleDeleteTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pagination controls */}
|
||||
{pageSize !== 'all' && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
|
||||
page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={!hasNextPage}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
|
||||
!hasNextPage ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
{/* Pagination controls */}
|
||||
{pageSize !== 'all' && (
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
|
||||
page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={!hasNextPage}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm transition-colors',
|
||||
!hasNextPage ? 'opacity-50 cursor-not-allowed' : 'hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Show:</span>
|
||||
<select
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
|
||||
}}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={String(opt)} value={String(opt)}>
|
||||
{opt === 'all' ? 'All' : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Show:</span>
|
||||
<select
|
||||
value={String(pageSize)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
|
||||
}}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={String(opt)} value={String(opt)}>
|
||||
{opt === 'all' ? 'All' : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{pageSize === 'all' && (
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Show:</span>
|
||||
<select
|
||||
value="all"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
|
||||
}}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={String(opt)} value={String(opt)}>
|
||||
{opt === 'all' ? 'All' : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pageSize === 'all' && (
|
||||
<div className="mt-4 flex items-center justify-end">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Show:</span>
|
||||
<select
|
||||
value="all"
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setPageSize(val === 'all' ? 'all' : parseInt(val, 10))
|
||||
}}
|
||||
className="rounded-md border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{pageSizeOptions.map((opt) => (
|
||||
<option key={String(opt)} value={String(opt)}>
|
||||
{opt === 'all' ? 'All' : opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fork Modal */}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { CSATModal } from '@/components/session/CSATModal'
|
||||
import { hasBeenRated } from '@/components/session/csatUtils'
|
||||
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
||||
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
||||
|
||||
interface LocationState {
|
||||
sessionId?: string
|
||||
@@ -60,6 +62,7 @@ export function TreeNavigationPage() {
|
||||
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
||||
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
||||
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
||||
const [copilotOpen, setCopilotOpen] = useState(false)
|
||||
|
||||
const handleCopyCommand = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
@@ -1270,6 +1273,20 @@ export function TreeNavigationPage() {
|
||||
onOpenChange={setScratchpadOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Copilot */}
|
||||
{treeId && (
|
||||
<>
|
||||
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||
<CopilotPanel
|
||||
isOpen={copilotOpen}
|
||||
onClose={() => setCopilotOpen(false)}
|
||||
treeId={treeId}
|
||||
sessionId={session?.id}
|
||||
currentNodeId={currentNodeId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
75
frontend/src/pages/VerifyEmailPage.tsx
Normal file
75
frontend/src/pages/VerifyEmailPage.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, Link } from 'react-router-dom'
|
||||
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function VerifyEmailPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('error')
|
||||
setErrorMessage('No verification token provided')
|
||||
return
|
||||
}
|
||||
|
||||
authApi.verifyEmail(token)
|
||||
.then(() => setStatus('success'))
|
||||
.catch((err) => {
|
||||
setStatus('error')
|
||||
const detail = (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||
setErrorMessage(detail ?? 'Verification failed')
|
||||
})
|
||||
}, [token])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="glass-card-static w-full max-w-md p-8 text-center">
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<Loader2 className="mx-auto h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 text-foreground">Verifying your email...</p>
|
||||
</>
|
||||
)}
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<CheckCircle2 className="mx-auto h-12 w-12 text-emerald-400" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Email Verified</h1>
|
||||
<p className="mt-2 text-muted-foreground">Your email has been successfully verified.</p>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-[10px] bg-gradient-brand px-6 py-2 text-sm font-semibold text-[#101114]',
|
||||
'shadow-lg shadow-primary/20 hover:opacity-90'
|
||||
)}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<XCircle className="mx-auto h-12 w-12 text-rose-500" />
|
||||
<h1 className="mt-4 text-xl font-bold font-heading text-foreground">Verification Failed</h1>
|
||||
<p className="mt-2 text-muted-foreground">{errorMessage}</p>
|
||||
<Link
|
||||
to="/"
|
||||
className={cn(
|
||||
'mt-6 inline-flex items-center rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-6 py-2 text-sm font-medium text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VerifyEmailPage
|
||||
119
frontend/src/pages/account/ChatRetentionSettingsPage.tsx
Normal file
119
frontend/src/pages/account/ChatRetentionSettingsPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Save, Loader2, Clock } from 'lucide-react'
|
||||
import { assistantChatApi } from '@/api/assistantChat'
|
||||
|
||||
export default function ChatRetentionSettingsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [retentionDays, setRetentionDays] = useState('')
|
||||
const [maxCount, setMaxCount] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
}, [])
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const data = await assistantChatApi.getRetentionSettings()
|
||||
setRetentionDays(data.chat_retention_days?.toString() ?? '90')
|
||||
setMaxCount(data.chat_retention_max_count?.toString() ?? '100')
|
||||
} catch {
|
||||
// silently handle
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSuccess(false)
|
||||
try {
|
||||
await assistantChatApi.updateRetentionSettings({
|
||||
chat_retention_days: parseInt(retentionDays) || null,
|
||||
chat_retention_max_count: parseInt(maxCount) || null,
|
||||
})
|
||||
setSuccess(true)
|
||||
setTimeout(() => setSuccess(false), 3000)
|
||||
} catch {
|
||||
// silently handle
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="animate-spin text-primary" size={24} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8 px-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Clock size={20} className="text-primary" />
|
||||
<h1 className="text-xl font-heading font-bold text-foreground">Chat Retention</h1>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static rounded-2xl p-6 space-y-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure how long AI assistant conversations are retained. Pinned chats are never automatically deleted.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-1.5">
|
||||
Retention Period (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={retentionDays}
|
||||
onChange={e => setRetentionDays(e.target.value)}
|
||||
min={1}
|
||||
max={365}
|
||||
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Chats older than this will be automatically deleted (1-365 days)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-1.5">
|
||||
Max Conversations
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxCount}
|
||||
onChange={e => setMaxCount(e.target.value)}
|
||||
min={10}
|
||||
max={10000}
|
||||
className="w-full rounded-xl border bg-card text-foreground text-sm px-4 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
When this limit is exceeded, oldest unpinned chats are deleted
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-5 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40 flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
Save Settings
|
||||
</button>
|
||||
{success && (
|
||||
<span className="text-sm text-emerald-400">Settings saved</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
frontend/src/pages/account/ProfileSettingsPage.tsx
Normal file
184
frontend/src/pages/account/ProfileSettingsPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { User as UserIcon, Loader2, AlertCircle, Check } from 'lucide-react'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { UserUpdate } from '@/types'
|
||||
|
||||
const inputClass = cn(
|
||||
'mt-1 block w-full rounded-[10px] border border-border bg-card px-3 py-2',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)
|
||||
|
||||
export function ProfileSettingsPage() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const fetchUser = useAuthStore((s) => s.fetchUser)
|
||||
|
||||
const [name, setName] = useState(user?.name ?? '')
|
||||
const [email, setEmail] = useState(user?.email ?? '')
|
||||
const [phone, setPhone] = useState(user?.phone ?? '')
|
||||
const [jobTitle, setJobTitle] = useState(user?.job_title ?? '')
|
||||
const [timezone, setTimezone] = useState(user?.timezone ?? 'UTC')
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const emailChanged = email !== user?.email
|
||||
const hasChanges =
|
||||
emailChanged ||
|
||||
name !== user?.name ||
|
||||
phone !== (user?.phone ?? '') ||
|
||||
jobTitle !== (user?.job_title ?? '') ||
|
||||
timezone !== (user?.timezone ?? 'UTC')
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!hasChanges) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload: UserUpdate = {}
|
||||
if (name !== user?.name) payload.name = name.trim()
|
||||
if (emailChanged) {
|
||||
payload.email = email.trim()
|
||||
payload.current_password = currentPassword
|
||||
}
|
||||
if (phone !== (user?.phone ?? '')) payload.phone = phone.trim() || null
|
||||
if (jobTitle !== (user?.job_title ?? '')) payload.job_title = jobTitle.trim() || null
|
||||
if (timezone !== (user?.timezone ?? 'UTC')) payload.timezone = timezone
|
||||
|
||||
await authApi.updateProfile(payload)
|
||||
await fetchUser()
|
||||
setCurrentPassword('')
|
||||
toast.success('Profile updated')
|
||||
} catch (err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } }
|
||||
setError(axiosErr.response?.data?.detail ?? 'Failed to update profile')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserIcon className="h-8 w-8 text-muted-foreground" />
|
||||
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Profile Settings</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Update your name, email, and personal details
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl">
|
||||
<form onSubmit={handleSave} className="glass-card-static p-6 space-y-5">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="profile-name" className="block text-sm font-medium text-foreground">Name</label>
|
||||
<input id="profile-name" type="text" value={name} onChange={(e) => setName(e.target.value)} required className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="profile-email" className="block text-sm font-medium text-foreground">Email</label>
|
||||
<input id="profile-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* Password confirmation for email change */}
|
||||
{emailChanged && (
|
||||
<div>
|
||||
<label htmlFor="profile-password" className="block text-sm font-medium text-foreground">Current Password</label>
|
||||
<p className="text-xs text-muted-foreground">Required to change your email address</p>
|
||||
<input id="profile-password" type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} required className={inputClass} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label htmlFor="profile-phone" className="block text-sm font-medium text-foreground">Phone</label>
|
||||
<input id="profile-phone" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Optional" className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* Job Title */}
|
||||
<div>
|
||||
<label htmlFor="profile-job-title" className="block text-sm font-medium text-foreground">Job Title</label>
|
||||
<input id="profile-job-title" type="text" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} placeholder="e.g. Network Engineer" className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* Timezone */}
|
||||
<div>
|
||||
<label htmlFor="profile-timezone" className="block text-sm font-medium text-foreground">Timezone</label>
|
||||
<select id="profile-timezone" value={timezone} onChange={(e) => setTimezone(e.target.value)} className={inputClass}>
|
||||
{COMMON_TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>{tz}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-rose-500">
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving || !hasChanges}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114]',
|
||||
'shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
Save Changes
|
||||
</button>
|
||||
|
||||
<Link
|
||||
to="/change-password"
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-[10px] px-4 py-2 text-sm font-medium',
|
||||
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
|
||||
'hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
Change Password
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const COMMON_TIMEZONES = [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Los_Angeles',
|
||||
'America/Anchorage',
|
||||
'Pacific/Honolulu',
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Amsterdam',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Dubai',
|
||||
'Australia/Sydney',
|
||||
'Australia/Melbourne',
|
||||
'Pacific/Auckland',
|
||||
]
|
||||
|
||||
export default ProfileSettingsPage
|
||||
@@ -18,6 +18,7 @@ export function SettingsPage() {
|
||||
|
||||
const maintenanceMode = Boolean(settings.maintenance_mode)
|
||||
const maintenanceMessage = String(settings.maintenance_message || '')
|
||||
const emailVerificationEnabled = settings.email_verification_enabled !== false
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
@@ -46,6 +47,27 @@ export function SettingsPage() {
|
||||
<PageHeader title="Platform Settings" description="Global platform configuration" />
|
||||
|
||||
<div className="max-w-xl space-y-6 bg-card border border-border rounded-xl p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Email Verification</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When enabled, unverified users see a banner prompting them to verify their email.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettings({ ...settings, email_verification_enabled: !emailVerificationEnabled })}
|
||||
className={cn(
|
||||
'h-6 w-10 rounded-full transition-colors',
|
||||
emailVerificationEnabled ? 'bg-gradient-brand' : 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'h-4 w-4 rounded-full bg-white transition-transform',
|
||||
emailVerificationEnabled ? 'translate-x-5' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Maintenance Mode</h3>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
|
||||
|
||||
// Standalone auth pages
|
||||
const VerifyEmailPage = lazy(() => import('@/pages/VerifyEmailPage'))
|
||||
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
|
||||
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
|
||||
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'))
|
||||
@@ -34,6 +35,9 @@ const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
||||
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
||||
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
||||
const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))
|
||||
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
||||
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
||||
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
|
||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||
// Admin pages
|
||||
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
||||
@@ -49,8 +53,10 @@ const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategor
|
||||
|
||||
// Account pages
|
||||
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
|
||||
const ProfileSettingsPage = lazy(() => import('@/pages/account/ProfileSettingsPage'))
|
||||
const TeamCategoriesPage = lazy(() => import('@/pages/account/TeamCategoriesPage'))
|
||||
const TargetListsPage = lazy(() => import('@/pages/account/TargetListsPage'))
|
||||
const ChatRetentionSettingsPage = lazy(() => import('@/pages/account/ChatRetentionSettingsPage'))
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -81,6 +87,15 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/verify-email',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<VerifyEmailPage />
|
||||
</Suspense>
|
||||
),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/share/:shareToken',
|
||||
element: (
|
||||
@@ -262,6 +277,30 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'assistant',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AssistantChatPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'guides',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<GuidesHubPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'guides/:slug',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<GuideDetailPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
// Admin routes
|
||||
{
|
||||
path: 'admin',
|
||||
@@ -364,6 +403,14 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'profile',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<ProfileSettingsPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'categories',
|
||||
element: (
|
||||
@@ -374,6 +421,16 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'chat-retention',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<ProtectedRoute requiredRole="owner">
|
||||
<ChatRetentionSettingsPage />
|
||||
</ProtectedRoute>
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'target-lists',
|
||||
element: (
|
||||
|
||||
37
frontend/src/types/assistant-chat.ts
Normal file
37
frontend/src/types/assistant-chat.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { SuggestedFlow } from './copilot'
|
||||
|
||||
export interface AssistantChat {
|
||||
id: string
|
||||
title: string
|
||||
messages: AssistantChatMessage[]
|
||||
message_count: number
|
||||
pinned: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AssistantChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ChatListItem {
|
||||
id: string
|
||||
title: string
|
||||
message_count: number
|
||||
pinned: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ChatMessageResponse {
|
||||
content: string
|
||||
suggested_flows: SuggestedFlow[]
|
||||
}
|
||||
|
||||
export interface RetentionSettings {
|
||||
chat_retention_days: number | null
|
||||
chat_retention_max_count: number | null
|
||||
}
|
||||
|
||||
export type { SuggestedFlow }
|
||||
41
frontend/src/types/copilot.ts
Normal file
41
frontend/src/types/copilot.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export interface SuggestedFlow {
|
||||
tree_id: string
|
||||
tree_name: string
|
||||
tree_type: string
|
||||
relevance_snippet: string
|
||||
}
|
||||
|
||||
export interface CopilotStartRequest {
|
||||
tree_id: string
|
||||
session_id?: string
|
||||
current_node_id?: string
|
||||
}
|
||||
|
||||
export interface CopilotStartResponse {
|
||||
conversation_id: string
|
||||
greeting: string
|
||||
}
|
||||
|
||||
export interface CopilotMessageRequest {
|
||||
message: string
|
||||
current_node_id?: string
|
||||
}
|
||||
|
||||
export interface CopilotMessageResponse {
|
||||
content: string
|
||||
suggested_flows: SuggestedFlow[]
|
||||
}
|
||||
|
||||
export interface CopilotConversation {
|
||||
id: string
|
||||
tree_id: string
|
||||
messages: CopilotMessage[]
|
||||
current_node_id?: string
|
||||
message_count: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CopilotMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export * from './step'
|
||||
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
|
||||
export * from './admin'
|
||||
export * from './analytics'
|
||||
export * from './copilot'
|
||||
export type { AssistantChat, AssistantChatMessage, ChatListItem, ChatMessageResponse, RetentionSettings } from './assistant-chat'
|
||||
|
||||
// API response wrapper types
|
||||
export interface PaginatedResponse<T> {
|
||||
|
||||
@@ -12,6 +12,11 @@ export interface User {
|
||||
account_role: 'owner' | 'engineer' | 'viewer' | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
phone: string | null
|
||||
job_title: string | null
|
||||
timezone: string
|
||||
avatar_url: string | null
|
||||
email_verified_at: string | null
|
||||
}
|
||||
|
||||
export interface UserCreate {
|
||||
@@ -30,4 +35,8 @@ export interface UserLogin {
|
||||
export interface UserUpdate {
|
||||
name?: string
|
||||
email?: string
|
||||
current_password?: string
|
||||
phone?: string | null
|
||||
job_title?: string | null
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user