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:
chihlasm
2026-03-04 20:44:25 -05:00
committed by GitHub
parent 554ad84d9e
commit 4d2c4930fd
100 changed files with 8181 additions and 466 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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) {

View 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>
)
}

View 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>
)
}

View 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))]">&middot;</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>
)
}

View 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>
)
}

View File

@@ -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
)}

View 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>
)
}

View File

@@ -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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
</>
)
}

View 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>
)
}

View File

@@ -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' },
]

View File

@@ -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" />
</>

View File

@@ -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}