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