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