chore: polish vision and practice components
- CalibrationOverlay: formatting fixes - useDeskViewCamera: add SSR guard and focusMode setting - ActiveSession: integrate vision mode changes - DocumentAdjuster/PhotoViewerEditor: component updates - SummaryClient: updates for practice summary - Drizzle meta: formatting consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
},
|
||||
@@ -1091,4 +1035,4 @@
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,4 +381,4 @@
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +294,11 @@ export function SummaryClient({
|
||||
|
||||
// Handle adjustment confirm - upload cropped + original with corners and rotation, process next
|
||||
const handleUploadAdjustmentConfirm = useCallback(
|
||||
async (croppedFile: File, corners: Array<{ x: number; y: number }>, rotation: 0 | 90 | 180 | 270) => {
|
||||
async (
|
||||
croppedFile: File,
|
||||
corners: Array<{ x: number; y: number }>,
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
) => {
|
||||
if (!uploadAdjustmentState) return
|
||||
|
||||
// Upload both cropped and original, with corners and rotation for later re-editing
|
||||
@@ -768,7 +772,6 @@ export function SummaryClient({
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)}
|
||||
|
||||
</PageWithNav>
|
||||
</SessionModeBannerProvider>
|
||||
)
|
||||
@@ -1028,7 +1031,11 @@ function FullscreenCamera({ onCapture, onClose }: FullscreenCameraProps) {
|
||||
|
||||
// Handle adjustment confirm - pass cropped file, original file, corners, and rotation for later re-editing
|
||||
const handleAdjustmentConfirm = useCallback(
|
||||
async (croppedFile: File, corners: Array<{ x: number; y: number }>, rotation: 0 | 90 | 180 | 270) => {
|
||||
async (
|
||||
croppedFile: File,
|
||||
corners: Array<{ x: number; y: number }>,
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
) => {
|
||||
if (!adjustmentMode) return
|
||||
|
||||
// Convert source canvas to file for original preservation
|
||||
|
||||
@@ -49,6 +49,8 @@ import { PracticeHelpOverlay } from './PracticeHelpOverlay'
|
||||
import { ProblemDebugPanel } from './ProblemDebugPanel'
|
||||
import { VerticalProblem } from './VerticalProblem'
|
||||
import type { ReceivedAbacusControl } from '@/hooks/useSessionBroadcast'
|
||||
import { AbacusVisionBridge } from '../vision'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
|
||||
/**
|
||||
* Timing data for the current problem attempt
|
||||
@@ -989,6 +991,9 @@ export function ActiveSession({
|
||||
// Track previous epoch to detect epoch changes
|
||||
const prevEpochRef = useRef<number>(0)
|
||||
|
||||
// Vision mode state - for physical abacus camera detection
|
||||
const [isVisionEnabled, setIsVisionEnabled] = useState(false)
|
||||
|
||||
// Browse mode state - isBrowseMode is controlled via props
|
||||
// browseIndex can be controlled (browseIndexProp + onBrowseIndexChange) or internal
|
||||
const [internalBrowseIndex, setInternalBrowseIndex] = useState(0)
|
||||
@@ -1314,6 +1319,17 @@ export function ActiveSession({
|
||||
[setAnswer]
|
||||
)
|
||||
|
||||
// Handle value detected from vision (physical abacus camera)
|
||||
const handleVisionValueDetected = useCallback(
|
||||
(value: number) => {
|
||||
// Update the docked abacus to show the detected value
|
||||
setDockedValue(value)
|
||||
// Also set the answer input
|
||||
setAnswer(String(value))
|
||||
},
|
||||
[setDockedValue, setAnswer]
|
||||
)
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
|
||||
@@ -1972,22 +1988,56 @@ export function ActiveSession({
|
||||
{/* Abacus dock - positioned absolutely so it doesn't affect problem centering */}
|
||||
{/* Width 100% matches problem width, height matches problem height */}
|
||||
{currentPart.type === 'abacus' && !showHelpOverlay && (problemHeight ?? 0) > 0 && (
|
||||
<AbacusDock
|
||||
id="practice-abacus"
|
||||
columns={calculateAbacusColumns(attempt.problem.terms)}
|
||||
interactive={true}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
onValueChange={handleAbacusDockValueChange}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
marginLeft: '1.5rem',
|
||||
})}
|
||||
style={{ height: problemHeight }}
|
||||
/>
|
||||
<>
|
||||
<AbacusDock
|
||||
id="practice-abacus"
|
||||
columns={calculateAbacusColumns(attempt.problem.terms)}
|
||||
interactive={true}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
onValueChange={handleAbacusDockValueChange}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
marginLeft: '1.5rem',
|
||||
})}
|
||||
style={{ height: problemHeight }}
|
||||
/>
|
||||
{/* Vision mode toggle button */}
|
||||
<button
|
||||
type="button"
|
||||
data-action="toggle-vision"
|
||||
data-enabled={isVisionEnabled}
|
||||
onClick={() => setIsVisionEnabled((prev) => !prev)}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
bottom: 0,
|
||||
marginLeft: '1.5rem',
|
||||
px: 2,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
fontSize: 'xs',
|
||||
bg: isVisionEnabled ? 'green.600' : isDark ? 'gray.700' : 'gray.200',
|
||||
color: isVisionEnabled ? 'white' : isDark ? 'gray.300' : 'gray.700',
|
||||
border: 'none',
|
||||
borderRadius: 'md',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: isVisionEnabled ? 'green.500' : isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
})}
|
||||
title="Use camera to detect physical abacus"
|
||||
>
|
||||
<span>📷</span>
|
||||
<span>Vision</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</animated.div>
|
||||
</animated.div>
|
||||
@@ -2072,6 +2122,27 @@ export function ActiveSession({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Abacus Vision Bridge - floating camera panel for physical abacus detection */}
|
||||
{isVisionEnabled && currentPart.type === 'abacus' && attempt && (
|
||||
<div
|
||||
data-component="vision-panel"
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '200px', // Below main nav (80px) + sub nav (~56px) + mini sub-nav (~60px)
|
||||
right: '1rem',
|
||||
zIndex: Z_INDEX.DROPDOWN, // Above content but below modals
|
||||
boxShadow: 'xl',
|
||||
borderRadius: 'xl',
|
||||
})}
|
||||
>
|
||||
<AbacusVisionBridge
|
||||
columnCount={calculateAbacusColumns(attempt.problem.terms)}
|
||||
onValueDetected={handleVisionValueDetected}
|
||||
onClose={() => setIsVisionEnabled(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Paused Modal - rendered here as single source of truth */}
|
||||
<SessionPausedModal
|
||||
isOpen={isPaused}
|
||||
|
||||
@@ -72,11 +72,14 @@ export function DocumentAdjuster({
|
||||
}, [detectQuadsInImage, sourceCanvas])
|
||||
|
||||
// Check if current corners match detected corners
|
||||
const cornersMatchDetected = !!(detectedCorners && corners.length === 4 && detectedCorners.length === 4 &&
|
||||
corners.every((c, i) =>
|
||||
Math.abs(c.x - detectedCorners[i].x) < 1 &&
|
||||
Math.abs(c.y - detectedCorners[i].y) < 1
|
||||
))
|
||||
const cornersMatchDetected = !!(
|
||||
detectedCorners &&
|
||||
corners.length === 4 &&
|
||||
detectedCorners.length === 4 &&
|
||||
corners.every(
|
||||
(c, i) => Math.abs(c.x - detectedCorners[i].x) < 1 && Math.abs(c.y - detectedCorners[i].y) < 1
|
||||
)
|
||||
)
|
||||
|
||||
// Calculate display scale to fit source image in container
|
||||
useEffect(() => {
|
||||
|
||||
@@ -63,7 +63,12 @@ export function PhotoViewerEditor({
|
||||
// Flag to prevent auto-reload after saving (which clears editState)
|
||||
const [editCompleted, setEditCompleted] = useState(false)
|
||||
|
||||
const { isReady: isDetectionReady, detectQuadsInImage, loadImageToCanvas, cv: opencvRef } = useDocumentDetection()
|
||||
const {
|
||||
isReady: isDetectionReady,
|
||||
detectQuadsInImage,
|
||||
loadImageToCanvas,
|
||||
cv: opencvRef,
|
||||
} = useDocumentDetection()
|
||||
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
@@ -151,7 +156,11 @@ export function PhotoViewerEditor({
|
||||
|
||||
// Handle edit confirm
|
||||
const handleEditConfirm = useCallback(
|
||||
async (croppedFile: File, corners: Array<{ x: number; y: number }>, rotation: 0 | 90 | 180 | 270) => {
|
||||
async (
|
||||
croppedFile: File,
|
||||
corners: Array<{ x: number; y: number }>,
|
||||
rotation: 0 | 90 | 180 | 270
|
||||
) => {
|
||||
if (!currentPhoto) return
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
@@ -283,10 +283,22 @@ export function CalibrationOverlay({
|
||||
|
||||
// Convert corners to display coordinates (accounting for letterbox offset)
|
||||
const displayCorners: QuadCorners = {
|
||||
topLeft: { x: corners.topLeft.x * scale + videoOffsetX, y: corners.topLeft.y * scale + videoOffsetY },
|
||||
topRight: { x: corners.topRight.x * scale + videoOffsetX, y: corners.topRight.y * scale + videoOffsetY },
|
||||
bottomLeft: { x: corners.bottomLeft.x * scale + videoOffsetX, y: corners.bottomLeft.y * scale + videoOffsetY },
|
||||
bottomRight: { x: corners.bottomRight.x * scale + videoOffsetX, y: corners.bottomRight.y * scale + videoOffsetY },
|
||||
topLeft: {
|
||||
x: corners.topLeft.x * scale + videoOffsetX,
|
||||
y: corners.topLeft.y * scale + videoOffsetY,
|
||||
},
|
||||
topRight: {
|
||||
x: corners.topRight.x * scale + videoOffsetX,
|
||||
y: corners.topRight.y * scale + videoOffsetY,
|
||||
},
|
||||
bottomLeft: {
|
||||
x: corners.bottomLeft.x * scale + videoOffsetX,
|
||||
y: corners.bottomLeft.y * scale + videoOffsetY,
|
||||
},
|
||||
bottomRight: {
|
||||
x: corners.bottomRight.x * scale + videoOffsetX,
|
||||
y: corners.bottomRight.y * scale + videoOffsetY,
|
||||
},
|
||||
}
|
||||
|
||||
// Create SVG path for the quadrilateral
|
||||
|
||||
@@ -119,6 +119,9 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn {
|
||||
video: {
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1440 },
|
||||
// Try to disable face-tracking auto-focus (not all cameras support this)
|
||||
// @ts-expect-error - focusMode is valid but not in TS types
|
||||
focusMode: 'continuous',
|
||||
...(targetDeviceId ? { deviceId: { exact: targetDeviceId } } : {}),
|
||||
},
|
||||
audio: false,
|
||||
@@ -188,6 +191,11 @@ export function useDeskViewCamera(): UseDeskViewCameraReturn {
|
||||
|
||||
// Listen for device changes (e.g., iPhone connected/disconnected)
|
||||
useEffect(() => {
|
||||
// Guard against SSR or unsupported environments
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleDeviceChange = () => {
|
||||
enumerateDevices()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user