feat: session sharing frontend (#76)
* feat: add session sharing types, API client, and utilities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SessionTimeline and ActionMenu reusable components SessionTimeline extracts timeline/checklist rendering from SessionDetailPage into a reusable component for both authenticated and public session views. ActionMenu provides a dropdown action menu with keyboard/click-outside dismiss. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add ShareSessionModal and integrate into SessionDetailPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Share Progress popover to TreeNavigationPage Replace the single "Copy for Ticket" button with a "Share Progress" popover that offers three actions: Copy Progress Summary (existing PSA export flow), Copy Share Link (auto-creates account-only share if needed), and Manage Share Links (opens ShareSessionModal). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add public SharedSessionPage with tree preview Add the public-facing shared session page at /share/:shareToken that renders shared sessions without authentication. Includes error handling for 401 (redirect to login), 403 (access denied), 404 (not found), and 410 (expired). The page features a minimal header, session metadata, SessionTimeline component, and a new SharedSessionTreePreview component that renders the decision tree structure with the path taken highlighted. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add My Shares management page with nav link Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address code review issues in session sharing - Add useCallback for loadShares in ShareSessionModal (React hook deps) - Use TreeStructure type instead of Record<string, unknown> for type safety - Fix login redirect format to match LoginPage's expected state shape Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add focused tests for session sharing utilities and API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve tree_structure type compatibility for shared session views - Use TreeStructure & Record<string, unknown> intersection for JSONB flexibility - Add explicit cast in SharedSessionTreePreview for recursive node rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add session sharing learnings to CLAUDE.md Add gotchas #12 (TreeStructure vs Tree types) and #13 (login redirect state format), note about npm run build strictness, and public route pattern to Common Tasks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: procedural editor UX improvements Add URL intake field type, fix variable name editing collapsing fields (index-based keys/updates), auto-generate variable names by field type, add section header as first-class step type, and simplify step editor with "More Options" collapsible for advanced fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: allow section_header step type in validation, improve tag input - Add 'section_header' to VALID_STEP_TYPES in backend validation so procedural flows with section headers can be published - Replace procedural editor's inline tag input with TagInput component (supports autocomplete, Tab, comma, semicolon, and paste splitting) - Add semicolon delimiter support to TagInput component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add type-aware routing for procedural flows Centralizes tree navigation routing via getTreeNavigatePath helper. Fixes all pages to route procedural sessions to /flows/:id/navigate instead of /trees/:id/navigate. Adds safety redirect in troubleshooting navigator and resume support in procedural navigator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unused index prop from IntakeFieldEditor 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 #76.
This commit is contained in:
100
frontend/src/components/common/ActionMenu.tsx
Normal file
100
frontend/src/components/common/ActionMenu.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { MoreVertical } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MenuAction {
|
||||
label: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
variant?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
interface ActionMenuProps {
|
||||
actions: MenuAction[]
|
||||
align?: 'left' | 'right' // default 'right'
|
||||
}
|
||||
|
||||
export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleItemClick = (action: MenuAction) => {
|
||||
if (action.disabled) return
|
||||
action.onClick()
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-2 text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
aria-label="Actions"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-50 mt-1 min-w-[180px] glass-card rounded-lg p-1',
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
)}
|
||||
>
|
||||
{actions.map((action, index) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleItemClick(action)}
|
||||
disabled={action.disabled}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
|
||||
action.disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: action.variant === 'destructive'
|
||||
? 'text-red-400 hover:bg-white/10 hover:text-red-300'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="h-4 w-4" />}
|
||||
{action.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type { MenuAction, ActionMenuProps }
|
||||
|
||||
export default ActionMenu
|
||||
@@ -106,10 +106,14 @@ export function TagInput({
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowSuggestions(false)
|
||||
setSelectedIndex(-1)
|
||||
} else if (e.key === ',' || e.key === 'Tab') {
|
||||
} else if (e.key === ',' || e.key === ';' || e.key === 'Tab') {
|
||||
if (inputValue.trim()) {
|
||||
e.preventDefault()
|
||||
addTag(inputValue)
|
||||
// Support multiple tags separated by commas or semicolons
|
||||
const parts = inputValue.split(/[,;]/).map(s => s.trim()).filter(Boolean)
|
||||
for (const part of parts) {
|
||||
addTag(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,7 +161,20 @@ export function TagInput({
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
// If pasted text contains delimiters, split into tags immediately
|
||||
if (val.includes(',') || val.includes(';')) {
|
||||
const parts = val.split(/[,;]/).map(s => s.trim()).filter(Boolean)
|
||||
const last = parts.pop()
|
||||
for (const part of parts) {
|
||||
addTag(part)
|
||||
}
|
||||
setInputValue(last || '')
|
||||
} else {
|
||||
setInputValue(val)
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
if (inputValue.length >= 1 && suggestions.length > 0) {
|
||||
@@ -221,7 +238,7 @@ export function TagInput({
|
||||
|
||||
{/* Helper text */}
|
||||
<p className="mt-1 text-xs text-white/40">
|
||||
{tags.length}/{maxTags} tags. Press Enter or comma to add.
|
||||
{tags.length}/{maxTags} tags. Press Enter, Tab, comma, or semicolon to add.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -84,6 +84,7 @@ export function AppLayout() {
|
||||
},
|
||||
{ path: '/my-trees', label: 'My Flows' },
|
||||
{ path: '/sessions', label: 'Sessions' },
|
||||
{ path: '/shares', label: 'My Shares' },
|
||||
{ path: '/account', label: 'Account' },
|
||||
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'ip_address', label: 'IP Address' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'url', label: 'URL' },
|
||||
{ value: 'select', label: 'Select (Dropdown)' },
|
||||
{ value: 'multi_select', label: 'Multi-Select' },
|
||||
{ value: 'checkbox', label: 'Checkbox' },
|
||||
|
||||
@@ -36,10 +36,10 @@ export function IntakeFormBuilder() {
|
||||
<div className="space-y-2">
|
||||
{intakeForm.map((field, index) => (
|
||||
<IntakeFieldEditor
|
||||
key={field.variable_name + '-' + index}
|
||||
key={`field-${index}-${field.display_order}`}
|
||||
field={field}
|
||||
onUpdate={(updates) => updateField(field.variable_name, updates)}
|
||||
onRemove={() => removeField(field.variable_name)}
|
||||
onUpdate={(updates) => updateField(index, updates)}
|
||||
onRemove={() => removeField(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronUp, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Type } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronUp, ChevronDown, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Settings2 } from 'lucide-react'
|
||||
import type { ProceduralStep, StepContentType, IntakeFormField } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -18,6 +19,35 @@ interface StepEditorProps {
|
||||
}
|
||||
|
||||
export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVariables }: StepEditorProps) {
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
|
||||
// Section header steps get a minimal editor
|
||||
if (step.type === 'section_header') {
|
||||
return (
|
||||
<div className="glass-card rounded-xl border border-white/10 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white/50">Edit Section Header</span>
|
||||
<button
|
||||
onClick={onCollapse}
|
||||
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
placeholder="Section title"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-xl border border-white/10 p-4">
|
||||
{/* Header */}
|
||||
@@ -48,55 +78,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content type + Section header row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
|
||||
<div className="flex gap-1">
|
||||
{CONTENT_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onUpdate({ content_type: opt.value })}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||
step.content_type === opt.value
|
||||
? 'bg-white/15 ' + opt.color
|
||||
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<Clock className="h-3 w-3" />
|
||||
Est. Minutes
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={step.estimated_minutes || ''}
|
||||
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
placeholder="—"
|
||||
min={1}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Header */}
|
||||
<div>
|
||||
{/* Est. Minutes */}
|
||||
<div className="w-40">
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<Type className="h-3 w-3" />
|
||||
Section Header (optional)
|
||||
<Clock className="h-3 w-3" />
|
||||
Est. Minutes
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.section_header || ''}
|
||||
onChange={(e) => onUpdate({ section_header: e.target.value || undefined })}
|
||||
placeholder="e.g. Phase 2: AD Configuration"
|
||||
type="number"
|
||||
value={step.estimated_minutes || ''}
|
||||
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
placeholder="—"
|
||||
min={1}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
@@ -127,23 +120,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning text */}
|
||||
{(step.content_type === 'warning' || step.warning_text) && (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Warning Text
|
||||
</label>
|
||||
<textarea
|
||||
value={step.warning_text || ''}
|
||||
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
|
||||
placeholder="Caution: This will restart the service..."
|
||||
rows={2}
|
||||
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commands */}
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
@@ -159,74 +135,127 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expected Outcome */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.expected_outcome || ''}
|
||||
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
|
||||
placeholder="Server should respond with..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
{/* More Options toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMore(!showMore)}
|
||||
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60"
|
||||
>
|
||||
<Settings2 className="h-3 w-3" />
|
||||
More Options
|
||||
{showMore ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
{/* Verification */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
Verification Prompt (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.verification_prompt || ''}
|
||||
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
|
||||
placeholder="Confirm the role was installed"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
|
||||
<select
|
||||
value={step.verification_type || ''}
|
||||
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="checkbox">Checkbox (confirm done)</option>
|
||||
<option value="text_input">Text input (enter value)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{showMore && (
|
||||
<div className="space-y-4 border-t border-white/[0.06] pt-4">
|
||||
{/* Content Type */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
|
||||
<div className="flex gap-1">
|
||||
{CONTENT_TYPE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onUpdate({ content_type: opt.value })}
|
||||
className={cn(
|
||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||
step.content_type === opt.value
|
||||
? 'bg-white/15 ' + opt.color
|
||||
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference URL + Notes toggle */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Reference URL (optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={step.reference_url || ''}
|
||||
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
|
||||
placeholder="https://learn.microsoft.com/..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-sm text-white/60">
|
||||
{/* Warning text */}
|
||||
{(step.content_type === 'warning' || step.warning_text) && (
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Warning Text
|
||||
</label>
|
||||
<textarea
|
||||
value={step.warning_text || ''}
|
||||
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
|
||||
placeholder="Caution: This will restart the service..."
|
||||
rows={2}
|
||||
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expected Outcome */}
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={step.notes_enabled !== false}
|
||||
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
|
||||
className="rounded border-white/20"
|
||||
type="text"
|
||||
value={step.expected_outcome || ''}
|
||||
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
|
||||
placeholder="Server should respond with..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
Allow tech notes
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Verification */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<CheckSquare className="h-3 w-3" />
|
||||
Verification Prompt (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={step.verification_prompt || ''}
|
||||
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
|
||||
placeholder="Confirm the role was installed"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
|
||||
<select
|
||||
value={step.verification_type || ''}
|
||||
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="checkbox">Checkbox (confirm done)</option>
|
||||
<option value="text_input">Text input (enter value)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference URL + Notes toggle */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Reference URL (optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={step.reference_url || ''}
|
||||
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
|
||||
placeholder="https://learn.microsoft.com/..."
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end pb-1">
|
||||
<label className="flex items-center gap-2 text-sm text-white/60">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={step.notes_enabled !== false}
|
||||
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
|
||||
className="rounded border-white/20"
|
||||
/>
|
||||
Allow tech notes
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield } from 'lucide-react'
|
||||
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react'
|
||||
import type { StepContentType } from '@/types'
|
||||
import { StepEditor } from './StepEditor'
|
||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||
@@ -18,11 +18,13 @@ export function StepList() {
|
||||
expandedStepId,
|
||||
setExpandedStepId,
|
||||
addStep,
|
||||
addSectionHeader,
|
||||
removeStep,
|
||||
updateStep,
|
||||
} = useProceduralEditorStore()
|
||||
|
||||
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
|
||||
let stepCounter = 0
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-6">
|
||||
@@ -34,19 +36,27 @@ export function StepList() {
|
||||
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addStep()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Step
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => addSectionHeader()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<SeparatorHorizontal className="h-3.5 w-3.5" />
|
||||
Add Section
|
||||
</button>
|
||||
<button
|
||||
onClick={() => addStep()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Step
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => {
|
||||
{steps.map((step) => {
|
||||
if (step.type === 'procedure_end') {
|
||||
// Render end step as a simple footer
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
@@ -65,20 +75,57 @@ export function StepList() {
|
||||
)
|
||||
}
|
||||
|
||||
// Section header rendering
|
||||
if (step.type === 'section_header') {
|
||||
const isExpanded = expandedStepId === step.id
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={step.id}>
|
||||
<StepEditor
|
||||
step={step}
|
||||
stepNumber={0}
|
||||
onUpdate={(updates) => updateStep(step.id, updates)}
|
||||
onCollapse={() => setExpandedStepId(null)}
|
||||
availableVariables={intakeForm}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="group flex items-center gap-2 border-b border-white/[0.06] pb-1 pt-3"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-white/20 group-hover:text-white/40" />
|
||||
<span
|
||||
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-white/40 hover:text-white/60"
|
||||
onClick={() => setExpandedStepId(step.id)}
|
||||
>
|
||||
{step.title || 'Untitled Section'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="shrink-0 rounded p-1 text-white/30 opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regular procedure step
|
||||
stepCounter++
|
||||
const stepNumber = stepCounter
|
||||
const isExpanded = expandedStepId === step.id
|
||||
const contentType = step.content_type || 'action'
|
||||
const config = contentTypeConfig[contentType]
|
||||
const Icon = config.icon
|
||||
const stepNumber = index + 1
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{step.section_header && (
|
||||
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
|
||||
{step.section_header}
|
||||
</div>
|
||||
)}
|
||||
<StepEditor
|
||||
step={step}
|
||||
stepNumber={stepNumber}
|
||||
@@ -92,11 +139,6 @@ export function StepList() {
|
||||
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{step.section_header && (
|
||||
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
|
||||
{step.section_header}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-xl border border-white/[0.06] px-3 py-2.5 transition-colors',
|
||||
|
||||
@@ -171,6 +171,18 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
||||
)
|
||||
break
|
||||
|
||||
case 'url':
|
||||
input = (
|
||||
<input
|
||||
type="url"
|
||||
value={value}
|
||||
onChange={(e) => setValue(field.variable_name, e.target.value)}
|
||||
placeholder={field.placeholder || 'https://...'}
|
||||
className={baseInputClass}
|
||||
/>
|
||||
)
|
||||
break
|
||||
|
||||
default: // text, ip_address, email
|
||||
input = (
|
||||
<input
|
||||
|
||||
207
frontend/src/components/session/SessionTimeline.tsx
Normal file
207
frontend/src/components/session/SessionTimeline.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState } from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import type { DecisionRecord } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SessionTimelineProps {
|
||||
decisions: DecisionRecord[]
|
||||
treeType?: string // 'procedural' or 'troubleshooting' (default)
|
||||
startedAt: string
|
||||
completedAt: string | null
|
||||
showCopyButtons?: boolean // default true
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
return new Date(dateString).toLocaleString()
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number | null | undefined) {
|
||||
if (durationSeconds == null || durationSeconds < 0) return null
|
||||
if (durationSeconds < 60) return `${durationSeconds}s`
|
||||
const hours = Math.floor(durationSeconds / 3600)
|
||||
const minutes = Math.floor((durationSeconds % 3600) / 60)
|
||||
const seconds = durationSeconds % 60
|
||||
if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
|
||||
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
|
||||
}
|
||||
|
||||
export function SessionTimeline({
|
||||
decisions,
|
||||
treeType,
|
||||
startedAt,
|
||||
completedAt,
|
||||
showCopyButtons = true,
|
||||
}: SessionTimelineProps) {
|
||||
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
|
||||
|
||||
const handleCopyStep = async (decision: DecisionRecord, index: number) => {
|
||||
const lines: string[] = []
|
||||
if (decision.question) lines.push(`Question: ${decision.question}`)
|
||||
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
|
||||
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
|
||||
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
|
||||
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
|
||||
try {
|
||||
await navigator.clipboard.writeText(lines.join('\n'))
|
||||
setCopiedStepIndex(index)
|
||||
setTimeout(() => setCopiedStepIndex(null), 2000)
|
||||
} catch {
|
||||
// Clipboard access denied
|
||||
}
|
||||
}
|
||||
|
||||
if (treeType === 'procedural') {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
|
||||
<div className="space-y-2">
|
||||
{decisions.map((decision, index) => {
|
||||
const isCompleted = decision.answer === 'completed'
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'glass-card rounded-xl p-4',
|
||||
isCompleted && 'border-l-2 border-emerald-400/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={cn(
|
||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
|
||||
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-white/10 text-white/50'
|
||||
)}>
|
||||
{isCompleted ? '\u2713' : index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-white">{decision.question || 'Step'}</p>
|
||||
{decision.notes && (
|
||||
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Verification: {decision.command_output}
|
||||
</p>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-1 text-xs text-white/30">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{showCopyButtons && (
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{completedAt && (
|
||||
<div className="flex items-center gap-3 pl-2 pt-2 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-emerald-500" />
|
||||
<span className="text-emerald-400">
|
||||
Procedure completed: {formatDate(completedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default: troubleshooting decision timeline
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-white" />
|
||||
<span className="text-white/40">
|
||||
Session started: {formatDate(startedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{decisions.map((decision, index) => (
|
||||
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
|
||||
<div className="relative">
|
||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
|
||||
<div className="glass-card rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
{decision.question && (
|
||||
<p className="font-medium text-white">{decision.question}</p>
|
||||
)}
|
||||
{decision.answer && (
|
||||
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
||||
)}
|
||||
{decision.action_performed && (
|
||||
<p className="mt-1 text-sm text-white/40">
|
||||
Action: {decision.action_performed}
|
||||
</p>
|
||||
)}
|
||||
{decision.notes && (
|
||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||
Notes: {decision.notes}
|
||||
</p>
|
||||
)}
|
||||
{decision.command_output && (
|
||||
<div className="mt-2">
|
||||
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
|
||||
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
|
||||
{decision.command_output}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{decision.duration_seconds != null && (
|
||||
<p className="mt-2 text-xs text-white/50">
|
||||
Duration: {formatDuration(decision.duration_seconds)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-white/40">
|
||||
{formatDate(decision.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
{showCopyButtons && (
|
||||
<button
|
||||
onClick={() => handleCopyStep(decision, index)}
|
||||
title="Copy step to clipboard"
|
||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copiedStepIndex === index ? (
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{completedAt && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="h-3 w-3 rounded-full bg-green-500" />
|
||||
<span className="text-emerald-400">
|
||||
Session completed: {formatDate(completedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionTimeline
|
||||
431
frontend/src/components/session/ShareSessionModal.tsx
Normal file
431
frontend/src/components/session/ShareSessionModal.tsx
Normal file
@@ -0,0 +1,431 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { X, Copy, Check, Globe, Users, Clock, Trash2, Link2 } from 'lucide-react'
|
||||
import type { SessionShare, SessionShareVisibility } from '@/types'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { buildSessionShareUrl, filterSharesForSession } from '@/lib/sessionShare'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface ShareSessionModalProps {
|
||||
sessionId: string
|
||||
sessionLabel: string // e.g. ticket number or "Session Details"
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type ExpirationPreset = 'never' | '1day' | '7days' | '30days' | 'custom'
|
||||
|
||||
function getRelativeTime(dateString: string): string {
|
||||
const now = Date.now()
|
||||
const date = new Date(dateString).getTime()
|
||||
const diffMs = now - date
|
||||
const diffSeconds = Math.floor(diffMs / 1000)
|
||||
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||
const diffHours = Math.floor(diffMinutes / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffSeconds < 60) return 'just now'
|
||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
|
||||
const diffMonths = Math.floor(diffDays / 30)
|
||||
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`
|
||||
}
|
||||
|
||||
function getExpirationLabel(expiresAt: string | null): { text: string; isExpired: boolean } {
|
||||
if (!expiresAt) return { text: 'No expiration', isExpired: false }
|
||||
const now = Date.now()
|
||||
const expiry = new Date(expiresAt).getTime()
|
||||
if (expiry <= now) return { text: 'Expired', isExpired: true }
|
||||
|
||||
const diffMs = expiry - now
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffDays > 0) return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false }
|
||||
if (diffHours > 0) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false }
|
||||
return { text: 'Expires soon', isExpired: false }
|
||||
}
|
||||
|
||||
function computeExpiresAt(preset: ExpirationPreset, customDatetime: string): string | undefined {
|
||||
if (preset === 'never') return undefined
|
||||
if (preset === 'custom') {
|
||||
if (!customDatetime) return undefined
|
||||
return new Date(customDatetime).toISOString()
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
switch (preset) {
|
||||
case '1day':
|
||||
now.setDate(now.getDate() + 1)
|
||||
break
|
||||
case '7days':
|
||||
now.setDate(now.getDate() + 7)
|
||||
break
|
||||
case '30days':
|
||||
now.setDate(now.getDate() + 30)
|
||||
break
|
||||
}
|
||||
return now.toISOString()
|
||||
}
|
||||
|
||||
export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }: ShareSessionModalProps) {
|
||||
const [shares, setShares] = useState<SessionShare[]>([])
|
||||
const [isLoadingShares, setIsLoadingShares] = useState(false)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [copiedShareId, setCopiedShareId] = useState<string | null>(null)
|
||||
|
||||
// Form state
|
||||
const [visibility, setVisibility] = useState<SessionShareVisibility>('account')
|
||||
const [shareName, setShareName] = useState('')
|
||||
const [expirationPreset, setExpirationPreset] = useState<ExpirationPreset>('never')
|
||||
const [customDatetime, setCustomDatetime] = useState('')
|
||||
const [visibilityError, setVisibilityError] = useState<string | null>(null)
|
||||
|
||||
const loadShares = useCallback(async () => {
|
||||
setIsLoadingShares(true)
|
||||
try {
|
||||
const allShares = await sessionsApi.listMyShares()
|
||||
const sessionShares = filterSharesForSession(allShares, sessionId)
|
||||
// Sort newest first
|
||||
sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
setShares(sessionShares)
|
||||
} catch (err) {
|
||||
console.error('Failed to load shares:', err)
|
||||
} finally {
|
||||
setIsLoadingShares(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadShares()
|
||||
// Reset form state
|
||||
setVisibility('account')
|
||||
setShareName('')
|
||||
setExpirationPreset('never')
|
||||
setCustomDatetime('')
|
||||
setVisibilityError(null)
|
||||
setCopiedShareId(null)
|
||||
}
|
||||
}, [isOpen, sessionId, loadShares])
|
||||
|
||||
const handleGenerateLink = async () => {
|
||||
setIsGenerating(true)
|
||||
setVisibilityError(null)
|
||||
try {
|
||||
const expires_at = computeExpiresAt(expirationPreset, customDatetime)
|
||||
const newShare = await sessionsApi.createShare(sessionId, {
|
||||
visibility,
|
||||
share_name: shareName.trim() || undefined,
|
||||
expires_at,
|
||||
})
|
||||
setShares([newShare, ...shares])
|
||||
toast.success('Share link generated')
|
||||
// Reset form
|
||||
setShareName('')
|
||||
setExpirationPreset('never')
|
||||
setCustomDatetime('')
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { status?: number; data?: { detail?: string } } }
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
error.response?.data?.detail?.toLowerCase().includes('public session sharing')
|
||||
) {
|
||||
setVisibilityError(error.response.data.detail ?? 'Organization does not allow public session sharing')
|
||||
} else {
|
||||
console.error('Failed to generate share link:', err)
|
||||
toast.error('Failed to generate share link')
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyUrl = async (share: SessionShare) => {
|
||||
try {
|
||||
const url = buildSessionShareUrl(share)
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopiedShareId(share.id)
|
||||
toast.success('Link copied to clipboard')
|
||||
setTimeout(() => setCopiedShareId(null), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy link:', err)
|
||||
toast.error('Failed to copy link')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (shareId: string) => {
|
||||
try {
|
||||
await sessionsApi.revokeShare(shareId)
|
||||
setShares(shares.filter((s) => s.id !== shareId))
|
||||
toast.success('Share link revoked')
|
||||
} catch (err) {
|
||||
console.error('Failed to revoke share:', err)
|
||||
toast.error('Failed to revoke share')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const presetButtons: { value: ExpirationPreset; label: string }[] = [
|
||||
{ value: 'never', label: 'Never' },
|
||||
{ value: '1day', label: '1 day' },
|
||||
{ value: '7days', label: '7 days' },
|
||||
{ value: '30days', label: '30 days' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg glass-card rounded-2xl shadow-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Share Session</h2>
|
||||
<p className="text-sm text-white/40">{sessionLabel}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-white/40 hover:bg-white/[0.06] hover:text-white"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="max-h-[60vh] overflow-y-auto px-6 py-4 space-y-6">
|
||||
{/* Create Share Form */}
|
||||
<div className="space-y-4">
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Visibility
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => { setVisibility('account'); setVisibilityError(null) }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||
visibility === 'account'
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">Account Only</div>
|
||||
<div className="text-xs text-white/40">Visible to your team</div>
|
||||
</div>
|
||||
{visibility === 'account' && (
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setVisibility('public'); setVisibilityError(null) }}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||
visibility === 'public'
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">Public</div>
|
||||
<div className="text-xs text-white/40">Anyone with the link</div>
|
||||
</div>
|
||||
{visibility === 'public' && (
|
||||
<div className="h-2 w-2 rounded-full bg-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{visibilityError && (
|
||||
<p className="mt-2 text-sm text-red-400">{visibilityError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share Name */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Share Name <span className="text-white/40">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={shareName}
|
||||
onChange={(e) => setShareName(e.target.value.slice(0, 100))}
|
||||
placeholder="e.g. Training link, Customer escalation"
|
||||
className={cn(
|
||||
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder-white/30',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||
)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expiration */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-white">
|
||||
Expiration
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{presetButtons.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => setExpirationPreset(preset.value)}
|
||||
className={cn(
|
||||
'rounded-md border px-3 py-1.5 text-sm transition-colors',
|
||||
expirationPreset === preset.value
|
||||
? 'border-white/20 bg-white/10 text-white'
|
||||
: 'border-white/10 text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{expirationPreset === 'custom' && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customDatetime}
|
||||
onChange={(e) => setCustomDatetime(e.target.value)}
|
||||
className={cn(
|
||||
'mt-2 w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||
'[color-scheme:dark]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
onClick={handleGenerateLink}
|
||||
disabled={isGenerating || (expirationPreset === 'custom' && !customDatetime)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
{isGenerating ? 'Generating...' : 'Generate Link'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Existing Shares */}
|
||||
{shares.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-white">
|
||||
Active Shares ({shares.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{shares.map((share) => {
|
||||
const expiration = getExpirationLabel(share.expires_at)
|
||||
const isCopied = copiedShareId === share.id
|
||||
return (
|
||||
<div
|
||||
key={share.id}
|
||||
className="glass-card rounded-xl p-4 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
|
||||
share.visibility === 'public'
|
||||
? 'bg-white/10 text-white/70'
|
||||
: 'bg-white/10 text-white/70'
|
||||
)}>
|
||||
{share.visibility === 'public' ? (
|
||||
<Globe className="h-3 w-3" />
|
||||
) : (
|
||||
<Users className="h-3 w-3" />
|
||||
)}
|
||||
{share.visibility === 'public' ? 'Public' : 'Account'}
|
||||
</span>
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
{share.share_name || 'Untitled share'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40">
|
||||
<span>{getRelativeTime(share.created_at)}</span>
|
||||
<span>
|
||||
{share.view_count > 0
|
||||
? `${share.view_count} view${share.view_count === 1 ? '' : 's'}`
|
||||
: 'Not viewed yet'}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'flex items-center gap-1',
|
||||
expiration.isExpired && 'text-red-400'
|
||||
)}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{expiration.text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={() => handleCopyUrl(share)}
|
||||
title="Copy share URL"
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 p-1.5 text-sm transition-colors',
|
||||
isCopied
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-white/50 hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRevoke(share.id)}
|
||||
title="Revoke share"
|
||||
className="rounded-md border border-white/10 p-1.5 text-white/50 hover:bg-red-500/10 hover:border-red-500/30 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoadingShares && shares.length === 0 && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||
'hover:bg-white/10 hover:text-white'
|
||||
)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShareSessionModal
|
||||
89
frontend/src/components/session/SharedSessionTreePreview.tsx
Normal file
89
frontend/src/components/session/SharedSessionTreePreview.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { TreeStructure } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SharedSessionTreePreviewProps {
|
||||
treeStructure: TreeStructure
|
||||
pathTaken: string[]
|
||||
}
|
||||
|
||||
const nodeTypeColors: Record<string, string> = {
|
||||
root: 'bg-white',
|
||||
decision: 'bg-blue-400',
|
||||
action: 'bg-yellow-400',
|
||||
solution: 'bg-emerald-400',
|
||||
information: 'bg-white/50',
|
||||
}
|
||||
|
||||
function getNodeTitle(node: Record<string, unknown>): string {
|
||||
return (
|
||||
(node.question as string) ||
|
||||
(node.title as string) ||
|
||||
(node.node_type as string) ||
|
||||
'Untitled'
|
||||
)
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
pathTaken,
|
||||
}: {
|
||||
node: Record<string, unknown>
|
||||
depth: number
|
||||
pathTaken: string[]
|
||||
}) {
|
||||
const nodeId = (node.id as string) || ''
|
||||
const nodeType = (node.node_type as string) || 'decision'
|
||||
const isInPath = pathTaken.includes(nodeId)
|
||||
const children = (node.children as Record<string, unknown>[]) || []
|
||||
const colorClass = nodeTypeColors[nodeType] || 'bg-white/50'
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 text-sm',
|
||||
isInPath
|
||||
? 'rounded-md border-l-2 border-white/40 bg-white/10 font-medium text-white'
|
||||
: 'text-white/30'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||
>
|
||||
<span
|
||||
className={cn('h-2 w-2 shrink-0 rounded-full', colorClass)}
|
||||
/>
|
||||
<span className="truncate">{getNodeTitle(node)}</span>
|
||||
</div>
|
||||
{children.map((child, index) => (
|
||||
<TreeNode
|
||||
key={(child.id as string) || index}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
pathTaken={pathTaken}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SharedSessionTreePreview({
|
||||
treeStructure,
|
||||
pathTaken,
|
||||
}: SharedSessionTreePreviewProps) {
|
||||
if (!treeStructure) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-2xl">
|
||||
<div className="sticky top-0 z-10 rounded-t-2xl border-b border-white/[0.06] bg-black/80 px-6 py-4 backdrop-blur">
|
||||
<h3 className="text-sm font-semibold text-white">Tree Structure</h3>
|
||||
</div>
|
||||
<div className="max-h-[600px] overflow-y-auto py-2">
|
||||
<TreeNode node={treeStructure as unknown as Record<string, unknown>} depth={0} pathTaken={pathTaken} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SharedSessionTreePreview
|
||||
Reference in New Issue
Block a user