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:
parent
7b4c7c3fb6
commit
085d200da4
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue