feat: improve shared worksheet viewer UX and multi-page support
Changes to shared worksheet viewer (/worksheets/shared/[id]): - Move Download, Share, Edit actions from banner to floating action menu - Main button shows "Edit" in read-only mode (most common action) - Download and Share available in dropdown menu - Simplified banner to show only read-only indicator - Made banner light/dark mode ready with theme-aware colors Changes to PreviewCenter component: - Added onShare and onEdit props for read-only mode - Main button adapts: "Edit" for read-only, "Download" for edit mode - Dropdown menu adapts based on mode (Download/Share vs Share/Upload) - Floating action button now always visible Changes to WorksheetPreview component: - Fixed virtualization to show all pages when initialData provided - Shared worksheets now display all pages immediately (no lazy loading) - Interactive editor still uses virtualization for performance - Added debug logging for page visibility and theme changes Bug fixes: - Fixed page virtualization preventing multiple pages from showing - Made shouldVirtualize persistent across re-renders - Added comprehensive logging for debugging theme and page issues 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d614904266
commit
1c10a82c78
|
|
@ -49,7 +49,10 @@
|
|||
"Bash(wc:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git cherry-pick:*)",
|
||||
"Bash(pnpm install)"
|
||||
"Bash(pnpm install)",
|
||||
"Bash(npx @biomejs/biome check:*)",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(sqlite3:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -116,13 +116,9 @@
|
|||
"abacus_settings_user_id_users_id_fk": {
|
||||
"name": "abacus_settings_user_id_users_id_fk",
|
||||
"tableFrom": "abacus_settings",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsFrom": ["user_id"],
|
||||
"tableTo": "users",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
|
|
@ -240,9 +236,7 @@
|
|||
"indexes": {
|
||||
"arcade_rooms_code_unique": {
|
||||
"name": "arcade_rooms_code_unique",
|
||||
"columns": [
|
||||
"code"
|
||||
],
|
||||
"columns": ["code"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
|
|
@ -339,26 +333,18 @@
|
|||
"arcade_sessions_room_id_arcade_rooms_id_fk": {
|
||||
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "arcade_sessions",
|
||||
"columnsFrom": [
|
||||
"room_id"
|
||||
],
|
||||
"columnsFrom": ["room_id"],
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"arcade_sessions_user_id_users_id_fk": {
|
||||
"name": "arcade_sessions_user_id_users_id_fk",
|
||||
"tableFrom": "arcade_sessions",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsFrom": ["user_id"],
|
||||
"tableTo": "users",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
|
|
@ -424,9 +410,7 @@
|
|||
"indexes": {
|
||||
"players_user_id_idx": {
|
||||
"name": "players_user_id_idx",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"columns": ["user_id"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
|
|
@ -434,13 +418,9 @@
|
|||
"players_user_id_users_id_fk": {
|
||||
"name": "players_user_id_users_id_fk",
|
||||
"tableFrom": "players",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsFrom": ["user_id"],
|
||||
"tableTo": "users",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
|
|
@ -514,9 +494,7 @@
|
|||
"indexes": {
|
||||
"idx_room_members_user_id_unique": {
|
||||
"name": "idx_room_members_user_id_unique",
|
||||
"columns": [
|
||||
"user_id"
|
||||
],
|
||||
"columns": ["user_id"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
|
|
@ -524,13 +502,9 @@
|
|||
"room_members_room_id_arcade_rooms_id_fk": {
|
||||
"name": "room_members_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "room_members",
|
||||
"columnsFrom": [
|
||||
"room_id"
|
||||
],
|
||||
"columnsFrom": ["room_id"],
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
|
|
@ -605,13 +579,9 @@
|
|||
"room_member_history_room_id_arcade_rooms_id_fk": {
|
||||
"name": "room_member_history_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "room_member_history",
|
||||
"columnsFrom": [
|
||||
"room_id"
|
||||
],
|
||||
"columnsFrom": ["room_id"],
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
|
|
@ -713,10 +683,7 @@
|
|||
"indexes": {
|
||||
"idx_room_invitations_user_room": {
|
||||
"name": "idx_room_invitations_user_room",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"room_id"
|
||||
],
|
||||
"columns": ["user_id", "room_id"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
|
|
@ -724,13 +691,9 @@
|
|||
"room_invitations_room_id_arcade_rooms_id_fk": {
|
||||
"name": "room_invitations_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "room_invitations",
|
||||
"columnsFrom": [
|
||||
"room_id"
|
||||
],
|
||||
"columnsFrom": ["room_id"],
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
|
|
@ -833,13 +796,9 @@
|
|||
"room_reports_room_id_arcade_rooms_id_fk": {
|
||||
"name": "room_reports_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "room_reports",
|
||||
"columnsFrom": [
|
||||
"room_id"
|
||||
],
|
||||
"columnsFrom": ["room_id"],
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
|
|
@ -918,10 +877,7 @@
|
|||
"indexes": {
|
||||
"idx_room_bans_user_room": {
|
||||
"name": "idx_room_bans_user_room",
|
||||
"columns": [
|
||||
"user_id",
|
||||
"room_id"
|
||||
],
|
||||
"columns": ["user_id", "room_id"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
|
|
@ -929,13 +885,9 @@
|
|||
"room_bans_room_id_arcade_rooms_id_fk": {
|
||||
"name": "room_bans_room_id_arcade_rooms_id_fk",
|
||||
"tableFrom": "room_bans",
|
||||
"columnsFrom": [
|
||||
"room_id"
|
||||
],
|
||||
"columnsFrom": ["room_id"],
|
||||
"tableTo": "arcade_rooms",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
|
|
@ -998,13 +950,9 @@
|
|||
"user_stats_user_id_users_id_fk": {
|
||||
"name": "user_stats_user_id_users_id_fk",
|
||||
"tableFrom": "user_stats",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsFrom": ["user_id"],
|
||||
"tableTo": "users",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
|
|
@ -1062,16 +1010,12 @@
|
|||
"indexes": {
|
||||
"users_guest_id_unique": {
|
||||
"name": "users_guest_id_unique",
|
||||
"columns": [
|
||||
"guest_id"
|
||||
],
|
||||
"columns": ["guest_id"],
|
||||
"isUnique": true
|
||||
},
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"columns": ["email"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -149,17 +149,19 @@ export function ActionsSidebar({ onGenerate, status }: ActionsSidebarProps) {
|
|||
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)',
|
||||
},
|
||||
_hover:
|
||||
isGeneratingShare || justCopied
|
||||
? {}
|
||||
: {
|
||||
bg: 'blue.700',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
_active:
|
||||
isGeneratingShare || justCopied
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(0)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{isGeneratingShare ? '⏳' : justCopied ? '✓' : '📋'}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ interface PreviewCenterProps {
|
|||
onGenerate: () => Promise<void>
|
||||
status: 'idle' | 'generating' | 'success' | 'error'
|
||||
isReadOnly?: boolean
|
||||
onShare?: () => Promise<void>
|
||||
onEdit?: () => void
|
||||
}
|
||||
|
||||
export function PreviewCenter({
|
||||
|
|
@ -25,6 +27,8 @@ export function PreviewCenter({
|
|||
onGenerate,
|
||||
status,
|
||||
isReadOnly = false,
|
||||
onShare,
|
||||
onEdit,
|
||||
}: PreviewCenterProps) {
|
||||
const router = useRouter()
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
|
@ -118,228 +122,343 @@ export function PreviewCenter({
|
|||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{/* Floating Action Button - Top Right (hidden in read-only mode) */}
|
||||
{!isReadOnly && (
|
||||
<div
|
||||
data-component="floating-action-button"
|
||||
{/* 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 Action Button - Edit in read-only mode, Download in edit mode */}
|
||||
<button
|
||||
type="button"
|
||||
data-action={isReadOnly ? 'edit-worksheet' : 'download-pdf'}
|
||||
onClick={isReadOnly ? onEdit : onGenerate}
|
||||
disabled={isGenerating}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '24',
|
||||
right: '4',
|
||||
zIndex: 1000,
|
||||
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',
|
||||
borderRadius: 'lg',
|
||||
overflow: 'hidden',
|
||||
shadow: 'lg',
|
||||
border: '2px solid',
|
||||
borderColor: 'brand.700',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2',
|
||||
transition: 'all 0.2s',
|
||||
_hover: isGenerating
|
||||
? {}
|
||||
: {
|
||||
bg: '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}
|
||||
{isReadOnly ? (
|
||||
<>
|
||||
<span className={css({ fontSize: 'lg' })}>✏️</span>
|
||||
<span>Edit</span>
|
||||
</>
|
||||
) : isGenerating ? (
|
||||
<>
|
||||
<div
|
||||
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',
|
||||
},
|
||||
w: '4',
|
||||
h: '4',
|
||||
border: '2px solid',
|
||||
borderColor: 'white',
|
||||
borderTopColor: 'transparent',
|
||||
rounded: 'full',
|
||||
animation: 'spin 1s linear infinite',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'xs' })}>▼</span>
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
/>
|
||||
<span>Generating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={css({ fontSize: 'lg' })}>⬇️</span>
|
||||
<span>Download</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<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
|
||||
{/* 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,
|
||||
})}
|
||||
>
|
||||
{/* Read-only mode shows Download and Share */}
|
||||
{isReadOnly ? (
|
||||
<>
|
||||
<DropdownMenu.Item
|
||||
data-action="download-worksheet"
|
||||
onClick={onGenerate}
|
||||
className={css({
|
||||
px: '4',
|
||||
py: '2.5',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'medium',
|
||||
color: 'gray.700',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
w: 'full',
|
||||
gap: '2',
|
||||
outline: 'none',
|
||||
_hover: {
|
||||
bg: 'blue.50',
|
||||
color: 'blue.700',
|
||||
},
|
||||
_focus: {
|
||||
bg: 'blue.50',
|
||||
color: 'blue.700',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Main share button - opens QR modal */}
|
||||
<button
|
||||
onClick={() => setIsShareModalOpen(true)}
|
||||
<span className={css({ fontSize: 'lg' })}>⬇️</span>
|
||||
<span>Download</span>
|
||||
</DropdownMenu.Item>
|
||||
|
||||
<DropdownMenu.Item
|
||||
data-action="share-worksheet"
|
||||
asChild
|
||||
className={css({
|
||||
outline: 'none',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
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',
|
||||
},
|
||||
justifyContent: 'space-between',
|
||||
w: 'full',
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: 'lg' })}>📱</span>
|
||||
<span>Share</span>
|
||||
</button>
|
||||
{/* Main share button - opens QR modal */}
|
||||
<button
|
||||
onClick={onShare}
|
||||
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}
|
||||
{/* 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="share-worksheet"
|
||||
asChild
|
||||
className={css({
|
||||
outline: 'none',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
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',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
w: 'full',
|
||||
})}
|
||||
title={justCopied ? 'Copied!' : 'Copy share link'}
|
||||
>
|
||||
{isGeneratingShare ? '⏳' : justCopied ? '✓' : '📋'}
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenu.Item>
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
{/* 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>
|
||||
|
||||
{/* Share Modal (hidden in read-only mode) */}
|
||||
<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 and Upload Modal (only shown in edit mode) */}
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<ShareModal
|
||||
|
|
@ -350,7 +469,6 @@ export function PreviewCenter({
|
|||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Upload Worksheet Modal */}
|
||||
<UploadWorksheetModal
|
||||
isOpen={isUploadModalOpen}
|
||||
onClose={() => setIsUploadModalOpen(false)}
|
||||
|
|
|
|||
|
|
@ -55,8 +55,6 @@ async function fetchWorksheetPreview(formState: WorksheetFormState): Promise<str
|
|||
function PreviewContent({ formState, initialData, isScrolling = false }: WorksheetPreviewProps) {
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
const [visiblePages, setVisiblePages] = useState<Set<number>>(new Set([0]))
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
// Track if we've used the initial data (so we only use it once)
|
||||
|
|
@ -114,16 +112,41 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
|
|||
|
||||
const totalPages = pages.length
|
||||
|
||||
// When initialData is provided (e.g., shared worksheets), show all pages immediately
|
||||
// Otherwise use virtualization for performance
|
||||
// Store this as state so it persists after initialData is consumed
|
||||
const [shouldVirtualize] = useState(() => !initialData)
|
||||
|
||||
// Initialize visible pages based on whether we should virtualize
|
||||
const [visiblePages, setVisiblePages] = useState<Set<number>>(() => {
|
||||
if (!shouldVirtualize && initialData) {
|
||||
// Show all pages immediately for pre-rendered content
|
||||
console.log('[WorksheetPreview] Initializing with all pages visible:', initialData.length)
|
||||
return new Set(Array.from({ length: initialData.length }, (_, i) => i))
|
||||
}
|
||||
console.log('[WorksheetPreview] Initializing with virtualization (page 0 only)')
|
||||
return new Set([0])
|
||||
})
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
|
||||
// Track when refs are fully populated
|
||||
const [refsReady, setRefsReady] = useState(false)
|
||||
|
||||
// Reset to first page and visible pages when preview updates
|
||||
useEffect(() => {
|
||||
setCurrentPage(0)
|
||||
setVisiblePages(new Set([0]))
|
||||
if (shouldVirtualize) {
|
||||
console.log('[WorksheetPreview] Resetting to virtualized view (page 0 only)')
|
||||
setVisiblePages(new Set([0]))
|
||||
} else {
|
||||
// Show all pages for non-virtualized view
|
||||
console.log('[WorksheetPreview] Showing all pages:', pages.length)
|
||||
setVisiblePages(new Set(Array.from({ length: pages.length }, (_, i) => i)))
|
||||
}
|
||||
pageRefs.current = []
|
||||
setRefsReady(false)
|
||||
}, [pages])
|
||||
}, [pages, shouldVirtualize])
|
||||
|
||||
// Check if all refs are populated after each render
|
||||
useEffect(() => {
|
||||
|
|
@ -135,8 +158,12 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
|
|||
}
|
||||
})
|
||||
|
||||
// Intersection Observer to track visible pages
|
||||
// Intersection Observer to track visible pages (only when virtualizing)
|
||||
useEffect(() => {
|
||||
if (!shouldVirtualize) {
|
||||
return // Skip virtualization when showing all pages
|
||||
}
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return // No need for virtualization with single page
|
||||
}
|
||||
|
|
@ -188,7 +215,7 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
|
|||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [totalPages, refsReady])
|
||||
}, [totalPages, refsReady, shouldVirtualize])
|
||||
|
||||
// Jump to page function for floating indicator
|
||||
const jumpToPage = (pageIndex: number) => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ export interface StudentNameInputProps {
|
|||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export function StudentNameInput({ value, onChange, isDark = false, readOnly = false }: StudentNameInputProps) {
|
||||
export function StudentNameInput({
|
||||
value,
|
||||
onChange,
|
||||
isDark = false,
|
||||
readOnly = false,
|
||||
}: StudentNameInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -27,12 +32,14 @@ export function StudentNameInput({ value, onChange, isDark = false, readOnly = f
|
|||
fontSize: 'sm',
|
||||
opacity: readOnly ? '0.7' : '1',
|
||||
cursor: readOnly ? 'not-allowed' : 'text',
|
||||
_focus: readOnly ? {} : {
|
||||
outline: 'none',
|
||||
borderColor: 'brand.500',
|
||||
ring: '2px',
|
||||
ringColor: 'brand.200',
|
||||
},
|
||||
_focus: readOnly
|
||||
? {}
|
||||
: {
|
||||
outline: 'none',
|
||||
borderColor: 'brand.500',
|
||||
ring: '2px',
|
||||
ringColor: 'brand.200',
|
||||
},
|
||||
_placeholder: { color: isDark ? 'gray.500' : 'gray.400' },
|
||||
})}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ export default function SharedWorksheetPage() {
|
|||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Debug: Log theme changes
|
||||
useEffect(() => {
|
||||
console.log('[SharedWorksheet] Theme changed:', { resolvedTheme, isDark })
|
||||
}, [resolvedTheme, isDark])
|
||||
|
||||
const [shareData, setShareData] = useState<ShareData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -39,6 +44,7 @@ export default function SharedWorksheetPage() {
|
|||
useEffect(() => {
|
||||
const fetchShare = async () => {
|
||||
try {
|
||||
console.log('[SharedWorksheet] Fetching share data for:', shareId)
|
||||
const response = await fetch(`/api/worksheets/share/${shareId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -51,6 +57,7 @@ export default function SharedWorksheetPage() {
|
|||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('[SharedWorksheet] Received share data, views:', data.views)
|
||||
setShareData(data)
|
||||
|
||||
// Fetch preview from API
|
||||
|
|
@ -64,9 +71,11 @@ export default function SharedWorksheetPage() {
|
|||
if (previewResponse.ok) {
|
||||
const previewData = await previewResponse.json()
|
||||
if (previewData.success) {
|
||||
console.log('[SharedWorksheet] Preview generated, page count:', previewData.pages.length)
|
||||
setPreview(previewData.pages)
|
||||
} else {
|
||||
// Preview generation failed - store error details
|
||||
console.error('[SharedWorksheet] Preview generation failed:', previewData)
|
||||
setPreviewError({
|
||||
error: previewData.error || 'Failed to generate preview',
|
||||
details: previewData.details,
|
||||
|
|
@ -250,155 +259,33 @@ export default function SharedWorksheetPage() {
|
|||
<div
|
||||
data-component="shared-mode-banner"
|
||||
className={css({
|
||||
bg: 'blue.600',
|
||||
bg: isDark ? 'blue.700' : 'blue.600',
|
||||
color: 'white',
|
||||
px: '6',
|
||||
py: '3',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '4',
|
||||
gap: '3',
|
||||
shadow: 'md',
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: isDark ? 'blue.600' : 'blue.700',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '3' })}>
|
||||
<span className={css({ fontSize: 'xl' })}>🔗</span>
|
||||
<div>
|
||||
<div className={css({ fontWeight: 'bold', fontSize: 'md' })}>
|
||||
Shared Worksheet (Read-Only)
|
||||
</div>
|
||||
<div className={css({ fontSize: 'sm', opacity: '0.9' })}>
|
||||
{shareData.title || `Shared by someone • ${shareData.views} views`}
|
||||
</div>
|
||||
<span className={css({ fontSize: 'xl' })}>🔗</span>
|
||||
<div>
|
||||
<div className={css({ fontWeight: 'bold', fontSize: 'md' })}>
|
||||
Shared Worksheet (Read-Only)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css({ display: 'flex', gap: '2', alignItems: 'center' })}>
|
||||
{/* Download Button */}
|
||||
<button
|
||||
data-action="download-worksheet"
|
||||
onClick={async () => {
|
||||
// Generate and download the worksheet
|
||||
const response = await fetch('/api/create/worksheets/addition', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
...shareData.config,
|
||||
date: new Date().toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (response.ok) {
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `worksheet-${shareData.id}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
}}
|
||||
<div
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: 'white',
|
||||
color: 'blue.600',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
_hover: {
|
||||
bg: 'blue.50',
|
||||
transform: 'translateY(-1px)',
|
||||
shadow: 'md',
|
||||
},
|
||||
opacity: isDark ? '0.85' : '0.9',
|
||||
color: isDark ? 'blue.100' : 'white',
|
||||
})}
|
||||
>
|
||||
<span>⬇️</span>
|
||||
</button>
|
||||
|
||||
{/* Share Button */}
|
||||
<button
|
||||
data-action="reshare-worksheet"
|
||||
onClick={async () => {
|
||||
// Create a new share link for this config
|
||||
const response = await fetch('/api/worksheets/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worksheetType: 'addition',
|
||||
config: shareData.config,
|
||||
}),
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
await navigator.clipboard.writeText(data.url)
|
||||
// TODO: Show toast notification
|
||||
alert('Share link copied to clipboard!')
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: 'white',
|
||||
color: 'blue.600',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
_hover: {
|
||||
bg: 'blue.50',
|
||||
transform: 'translateY(-1px)',
|
||||
shadow: 'md',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>🔗</span>
|
||||
</button>
|
||||
|
||||
{/* Edit Button */}
|
||||
<button
|
||||
data-action="open-edit-modal"
|
||||
onClick={() => setShowEditModal(true)}
|
||||
className={css({
|
||||
px: '4',
|
||||
py: '2',
|
||||
bg: 'white',
|
||||
color: 'blue.600',
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
_hover: {
|
||||
bg: 'blue.50',
|
||||
transform: 'translateY(-1px)',
|
||||
shadow: 'md',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>✏️</span>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
{shareData.title || `Shared by someone • ${shareData.views} views`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -511,9 +398,54 @@ export default function SharedWorksheetPage() {
|
|||
<PreviewCenter
|
||||
formState={shareData.config}
|
||||
initialPreview={preview}
|
||||
onGenerate={async () => {}} // No-op for read-only
|
||||
onGenerate={async () => {
|
||||
// Generate and download the worksheet
|
||||
const response = await fetch('/api/create/worksheets/addition', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config: {
|
||||
...shareData.config,
|
||||
date: new Date().toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (response.ok) {
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `worksheet-${shareData.id}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
}}
|
||||
status="idle"
|
||||
isReadOnly={true}
|
||||
onShare={async () => {
|
||||
// Create a new share link for this config
|
||||
const response = await fetch('/api/worksheets/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worksheetType: 'addition',
|
||||
config: shareData.config,
|
||||
}),
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
await navigator.clipboard.writeText(data.url)
|
||||
// TODO: Show toast notification
|
||||
alert('Share link copied to clipboard!')
|
||||
}
|
||||
}}
|
||||
onEdit={() => setShowEditModal(true)}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
|
|
|
|||
Loading…
Reference in New Issue