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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user