feat: add split-action share button with copy shortcut

Add intuitive share UI with dual functionality:

PreviewCenter (Dropdown Menu):
- Single menu item: "📱 Share" (opens QR modal) | "📋" (quick copy)
- Copy button on right side with visual feedback
- Shows ✓ checkmark + green highlight for 2 seconds after copy
- Loading state () during share link generation

ActionsSidebar (Full Buttons):
- Button group: "📱 Share" takes most space | "📋" copy shortcut
- Same visual feedback and loading states
- Maintains button styling consistency

Quick Copy Flow:
- Lazy generation: Creates share link only when copy is clicked
- Auto-copies to clipboard without opening modal
- Visual confirmation: Icon changes to ✓, background turns green
- Error handling with console logs

Both UIs provide:
- Main action: Opens QR code modal for sharing
- Shortcut action: Direct clipboard copy
- Disabled state during generation
- Hover effects on both parts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-11 11:17:01 -06:00
parent 7b4c7c3fb6
commit 085d200da4
2 changed files with 591 additions and 0 deletions

View File

@ -0,0 +1,224 @@
'use client'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetModal'
import { useTheme } from '@/contexts/ThemeContext'
import { GenerateButton } from './GenerateButton'
import { ShareModal } from './ShareModal'
import { useWorksheetConfig } from './WorksheetConfigContext'
interface ActionsSidebarProps {
onGenerate: () => Promise<void>
status: 'idle' | 'generating' | 'success' | 'error'
}
export function ActionsSidebar({ onGenerate, status }: ActionsSidebarProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const router = useRouter()
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
const [isGeneratingShare, setIsGeneratingShare] = useState(false)
const [justCopied, setJustCopied] = useState(false)
const { formState } = useWorksheetConfig()
// Upload complete handler
const handleUploadComplete = (attemptId: string) => {
router.push(`/worksheets/attempts/${attemptId}`)
}
// Quick share - copy link to clipboard without showing modal
const handleQuickShare = async () => {
setIsGeneratingShare(true)
setJustCopied(false)
try {
const response = await fetch('/api/worksheets/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worksheetType: 'addition',
config: formState,
}),
})
if (!response.ok) {
throw new Error('Failed to create share link')
}
const data = await response.json()
await navigator.clipboard.writeText(data.url)
setJustCopied(true)
setTimeout(() => setJustCopied(false), 2000)
} catch (err) {
console.error('Failed to create share link:', err)
// TODO: Show error toast
} finally {
setIsGeneratingShare(false)
}
}
return (
<>
<div
data-component="actions-sidebar"
className={css({
h: 'full',
bg: isDark ? 'gray.800' : 'white',
rounded: 'xl',
shadow: 'card',
p: '4',
overflow: 'auto',
})}
>
<div className={stack({ gap: '4' })}>
{/* Generate Button */}
<GenerateButton
status={status === 'success' ? 'idle' : status}
onGenerate={onGenerate}
isDark={isDark}
/>
{/* Share Button with Copy Shortcut */}
<div
data-component="share-button-group"
className={css({
w: 'full',
display: 'flex',
rounded: 'xl',
overflow: 'hidden',
shadow: 'md',
border: '2px solid',
borderColor: 'blue.700',
})}
>
{/* Main share button - opens QR modal */}
<button
data-action="show-qr-code"
onClick={() => setIsShareModalOpen(true)}
className={css({
flex: '1',
px: '6',
py: '4',
bg: 'blue.600',
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
transition: 'all 0.2s',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2',
border: 'none',
outline: 'none',
_hover: {
bg: 'blue.700',
transform: 'translateY(-1px)',
},
_active: {
transform: 'translateY(0)',
},
})}
>
<span className={css({ fontSize: 'xl' })}>📱</span>
<span>Share</span>
</button>
{/* Copy shortcut */}
<button
data-action="copy-share-link"
onClick={handleQuickShare}
disabled={isGeneratingShare}
title={justCopied ? 'Copied!' : 'Copy share link'}
className={css({
px: '4',
py: '4',
bg: justCopied ? 'green.600' : 'blue.600',
color: 'white',
fontSize: 'xl',
cursor: isGeneratingShare ? 'wait' : 'pointer',
border: 'none',
borderLeft: '1px solid',
borderColor: justCopied ? 'green.700' : 'blue.700',
outline: 'none',
transition: 'all 0.2s',
opacity: isGeneratingShare ? '0.6' : '1',
_hover: isGeneratingShare || justCopied
? {}
: {
bg: 'blue.700',
transform: 'translateY(-1px)',
},
_active: isGeneratingShare || justCopied
? {}
: {
transform: 'translateY(0)',
},
})}
>
{isGeneratingShare ? '⏳' : justCopied ? '✓' : '📋'}
</button>
</div>
{/* Upload Worksheet Button */}
<button
data-action="upload-worksheet"
onClick={() => setIsUploadModalOpen(true)}
className={css({
w: 'full',
px: '6',
py: '4',
bg: 'purple.600',
color: 'white',
fontSize: 'md',
fontWeight: 'bold',
rounded: 'xl',
shadow: 'md',
transition: 'all 0.2s',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2',
border: '2px solid',
borderColor: 'purple.700',
_hover: {
bg: 'purple.700',
borderColor: 'purple.800',
transform: 'translateY(-1px)',
shadow: 'lg',
},
_active: {
transform: 'translateY(0)',
},
})}
>
<span className={css({ fontSize: 'xl' })}></span>
<span>Upload</span>
</button>
</div>
</div>
{/* Share Modal */}
<ShareModal
isOpen={isShareModalOpen}
onClose={() => setIsShareModalOpen(false)}
worksheetType="addition"
config={formState}
isDark={isDark}
/>
{/* Upload Worksheet Modal */}
<UploadWorksheetModal
isOpen={isUploadModalOpen}
onClose={() => setIsUploadModalOpen(false)}
onUploadComplete={handleUploadComplete}
/>
</>
)
}

View File

@ -0,0 +1,367 @@
'use client'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { css } from '@styled/css'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState } from 'react'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetModal'
import { useTheme } from '@/contexts/ThemeContext'
import { ShareModal } from './ShareModal'
import { WorksheetPreview } from './WorksheetPreview'
interface PreviewCenterProps {
formState: WorksheetFormState
initialPreview?: string[]
onGenerate: () => Promise<void>
status: 'idle' | 'generating' | 'success' | 'error'
}
export function PreviewCenter({
formState,
initialPreview,
onGenerate,
status,
}: PreviewCenterProps) {
const router = useRouter()
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const scrollContainerRef = useRef<HTMLDivElement>(null)
const [isScrolling, setIsScrolling] = useState(false)
const scrollTimeoutRef = useRef<NodeJS.Timeout>()
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
const [isGeneratingShare, setIsGeneratingShare] = useState(false)
const [justCopied, setJustCopied] = useState(false)
const isGenerating = status === 'generating'
// Detect scrolling in the scroll container
useEffect(() => {
const container = scrollContainerRef.current
if (!container) return
const handleScroll = () => {
setIsScrolling(true)
// Clear existing timeout
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current)
}
// Set new timeout to hide after 1 second of no scrolling
scrollTimeoutRef.current = setTimeout(() => {
setIsScrolling(false)
}, 1000)
}
container.addEventListener('scroll', handleScroll, { passive: true })
return () => {
container.removeEventListener('scroll', handleScroll)
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current)
}
}
}, [])
// Upload complete handler
const handleUploadComplete = (attemptId: string) => {
router.push(`/worksheets/attempts/${attemptId}`)
}
// Quick share - copy link to clipboard without showing modal
const handleQuickShare = async () => {
setIsGeneratingShare(true)
setJustCopied(false)
try {
const response = await fetch('/api/worksheets/share', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worksheetType: 'addition',
config: formState,
}),
})
if (!response.ok) {
throw new Error('Failed to create share link')
}
const data = await response.json()
await navigator.clipboard.writeText(data.url)
setJustCopied(true)
setTimeout(() => setJustCopied(false), 2000)
} catch (err) {
console.error('Failed to create share link:', err)
// TODO: Show error toast
} finally {
setIsGeneratingShare(false)
}
}
return (
<div
ref={scrollContainerRef}
data-component="preview-center"
className={css({
h: 'full',
w: 'full',
overflow: 'auto',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
position: 'relative',
})}
>
{/* Floating Action Button - Top Right */}
<div
data-component="floating-action-button"
className={css({
position: 'fixed',
top: '24',
right: '4',
zIndex: 1000,
display: 'flex',
borderRadius: 'lg',
overflow: 'hidden',
shadow: 'lg',
border: '2px solid',
borderColor: 'brand.700',
})}
>
{/* Main Download Button */}
<button
type="button"
data-action="download-pdf"
onClick={onGenerate}
disabled={isGenerating}
className={css({
px: '4',
py: '2.5',
bg: 'brand.600',
color: 'white',
fontSize: 'sm',
fontWeight: 'bold',
cursor: isGenerating ? 'not-allowed' : 'pointer',
opacity: isGenerating ? '0.7' : '1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '2',
transition: 'all 0.2s',
_hover: isGenerating
? {}
: {
bg: 'brand.700',
},
})}
>
{isGenerating ? (
<>
<div
className={css({
w: '4',
h: '4',
border: '2px solid',
borderColor: 'white',
borderTopColor: 'transparent',
rounded: 'full',
animation: 'spin 1s linear infinite',
})}
/>
<span>Generating...</span>
</>
) : (
<>
<span className={css({ fontSize: 'lg' })}></span>
<span>Download</span>
</>
)}
</button>
{/* Dropdown Trigger */}
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
data-action="open-actions-dropdown"
disabled={isGenerating}
className={css({
px: '2',
bg: 'brand.600',
color: 'white',
cursor: isGenerating ? 'not-allowed' : 'pointer',
opacity: isGenerating ? '0.7' : '1',
borderLeft: '1px solid',
borderColor: 'brand.700',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
_hover: isGenerating
? {}
: {
bg: 'brand.700',
},
})}
>
<span className={css({ fontSize: 'xs' })}></span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
sideOffset={4}
className={css({
bg: 'white',
borderRadius: 'lg',
shadow: 'lg',
border: '1px solid',
borderColor: 'gray.200',
overflow: 'hidden',
minW: '160px',
zIndex: 10000,
})}
>
<DropdownMenu.Item
data-action="share-worksheet"
asChild
className={css({
outline: 'none',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
w: 'full',
})}
>
{/* Main share button - opens QR modal */}
<button
onClick={() => setIsShareModalOpen(true)}
className={css({
flex: '1',
px: '4',
py: '2.5',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '2',
outline: 'none',
bg: 'transparent',
border: 'none',
_hover: {
bg: 'blue.50',
color: 'blue.700',
},
})}
>
<span className={css({ fontSize: 'lg' })}>📱</span>
<span>Share</span>
</button>
{/* Copy shortcut */}
<button
onClick={(e) => {
e.stopPropagation()
handleQuickShare()
}}
disabled={isGeneratingShare}
className={css({
px: '3',
py: '2.5',
fontSize: 'lg',
color: justCopied ? 'green.700' : 'gray.600',
cursor: isGeneratingShare ? 'wait' : 'pointer',
bg: justCopied ? 'green.50' : 'transparent',
border: 'none',
borderLeft: '1px solid',
borderColor: 'gray.200',
outline: 'none',
opacity: isGeneratingShare ? '0.6' : '1',
transition: 'all 0.2s',
_hover: isGeneratingShare || justCopied
? {}
: {
bg: 'green.50',
color: 'green.700',
},
})}
title={justCopied ? 'Copied!' : 'Copy share link'}
>
{isGeneratingShare ? '⏳' : justCopied ? '✓' : '📋'}
</button>
</div>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="upload-worksheet"
onClick={() => setIsUploadModalOpen(true)}
className={css({
px: '4',
py: '2.5',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '2',
outline: 'none',
_hover: {
bg: 'purple.50',
color: 'purple.700',
},
_focus: {
bg: 'purple.50',
color: 'purple.700',
},
})}
>
<span className={css({ fontSize: 'lg' })}></span>
<span>Upload</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
{/* Share Modal */}
<ShareModal
isOpen={isShareModalOpen}
onClose={() => setIsShareModalOpen(false)}
worksheetType="addition"
config={formState}
isDark={isDark}
/>
{/* Upload Worksheet Modal */}
<UploadWorksheetModal
isOpen={isUploadModalOpen}
onClose={() => setIsUploadModalOpen(false)}
onUploadComplete={handleUploadComplete}
/>
<div
className={css({
w: 'full',
maxW: '1000px',
minH: 'full',
})}
>
<WorksheetPreview
formState={formState}
initialData={initialPreview}
isScrolling={isScrolling}
/>
</div>
</div>
)
}