| import { useEffect, useRef, useState } from 'react'; |
| import type { PointerEvent as ReactPointerEvent } from 'react'; |
| import type { ProblemFramingPlan } from '../types/api'; |
| import { useI18n } from '../i18n'; |
|
|
| interface ProblemFramingOverlayProps { |
| open: boolean; |
| status: 'loading' | 'ready' | 'error'; |
| plan: ProblemFramingPlan | null; |
| error: string | null; |
| adjustment: string; |
| generating: boolean; |
| onAdjustmentChange: (value: string) => void; |
| onRetry: () => void; |
| onGenerate: () => void; |
| onClose: () => void; |
| } |
|
|
| const CARD_LAYOUTS = [ |
| { x: 6, y: 14, rotate: -6 }, |
| { x: 21, y: 57, rotate: 4 }, |
| { x: 42, y: 22, rotate: -4 }, |
| { x: 62, y: 52, rotate: 5 }, |
| { x: 79, y: 18, rotate: -3 }, |
| { x: 74, y: 66, rotate: 3 }, |
| ]; |
|
|
| const COLLAPSED_CARD = { width: 120, height: 86 }; |
| const EXPANDED_CARD = { width: 272, height: 236 }; |
| const ACTIVE_REPEL_DISTANCE = 14; |
| const ACTIVE_CARD_CENTER_PULL = 0.28; |
|
|
| type CardPosition = { |
| x: number; |
| y: number; |
| rotate: number; |
| }; |
|
|
| type DragState = { |
| index: number; |
| pointerId: number; |
| startClientX: number; |
| startClientY: number; |
| originX: number; |
| originY: number; |
| moved: boolean; |
| }; |
|
|
| function PawStamp({ delay = 0 }: { delay?: number }) { |
| return ( |
| <div |
| className="problem-paw text-text-secondary/55" |
| style={{ animationDelay: `${delay}s` }} |
| > |
| <svg width="34" height="34" viewBox="0 0 24 24" fill="currentColor"> |
| <ellipse cx="12" cy="15" rx="5.2" ry="4.1" /> |
| <circle cx="7" cy="9.1" r="2.15" /> |
| <circle cx="12" cy="6.8" r="2.15" /> |
| <circle cx="17" cy="9.1" r="2.15" /> |
| </svg> |
| </div> |
| ); |
| } |
|
|
| function WaitingPaws() { |
| return ( |
| <div className="pointer-events-none absolute right-6 top-5 flex items-center gap-2 opacity-80"> |
| <PawStamp /> |
| <PawStamp delay={0.18} /> |
| <PawStamp delay={0.36} /> |
| </div> |
| ); |
| } |
|
|
| function CanvasTransition() { |
| return ( |
| <div className="pointer-events-none absolute inset-0"> |
| <div className="absolute left-[12%] top-[24%] h-28 w-28 rounded-full bg-bg-primary/35 blur-2xl animate-pulse" /> |
| <div className="absolute right-[16%] top-[18%] h-20 w-36 rounded-full bg-bg-primary/30 blur-2xl animate-pulse [animation-delay:180ms]" /> |
| <div className="absolute left-[36%] bottom-[18%] h-24 w-40 rounded-full bg-bg-primary/28 blur-2xl animate-pulse [animation-delay:320ms]" /> |
| </div> |
| ); |
| } |
|
|
| export function ProblemFramingOverlay({ |
| open, |
| status, |
| plan, |
| error, |
| adjustment, |
| generating, |
| onAdjustmentChange, |
| onRetry, |
| onGenerate, |
| onClose, |
| }: ProblemFramingOverlayProps) { |
| if (!open) { |
| return null; |
| } |
|
|
| const planKey = plan |
| ? `${status}-${plan.summary}-${plan.steps.map((step) => `${step.title}:${step.content}`).join('|')}` |
| : `empty-${status}`; |
|
|
| return ( |
| <ProblemFramingOverlayContent |
| key={planKey} |
| status={status} |
| plan={plan} |
| error={error} |
| adjustment={adjustment} |
| generating={generating} |
| onAdjustmentChange={onAdjustmentChange} |
| onRetry={onRetry} |
| onGenerate={onGenerate} |
| onClose={onClose} |
| /> |
| ); |
| } |
|
|
| interface ProblemFramingOverlayContentProps { |
| status: 'loading' | 'ready' | 'error'; |
| plan: ProblemFramingPlan | null; |
| error: string | null; |
| adjustment: string; |
| generating: boolean; |
| onAdjustmentChange: (value: string) => void; |
| onRetry: () => void; |
| onGenerate: () => void; |
| onClose: () => void; |
| } |
|
|
| function ProblemFramingOverlayContent({ |
| status, |
| plan, |
| error, |
| adjustment, |
| generating, |
| onAdjustmentChange, |
| onRetry, |
| onGenerate, |
| onClose, |
| }: ProblemFramingOverlayContentProps) { |
| const { t } = useI18n(); |
| const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); |
| const [activeStepIndex, setActiveStepIndex] = useState<number | null>(null); |
| const [draftSteps, setDraftSteps] = useState<Array<{ title: string; content: string }>>(() => |
| plan ? plan.steps.map((step) => ({ title: step.title, content: step.content })) : [] |
| ); |
| const [cardPositions, setCardPositions] = useState<CardPosition[]>(() => |
| plan |
| ? plan.steps.map((_, index) => { |
| const layout = CARD_LAYOUTS[index] || CARD_LAYOUTS[index % CARD_LAYOUTS.length]; |
| return { x: layout.x, y: layout.y, rotate: layout.rotate }; |
| }) |
| : [] |
| ); |
| const canvasRef = useRef<HTMLDivElement | null>(null); |
| const dragStateRef = useRef<DragState | null>(null); |
| const statusKey = |
| status === 'loading' |
| ? 'problem.status.loading' |
| : status === 'ready' |
| ? 'problem.status.ready' |
| : 'problem.status.error'; |
|
|
| useEffect(() => { |
| dragStateRef.current = null; |
| }, []); |
|
|
| const stepCount = plan?.steps.length || 4; |
|
|
| const updateCardPosition = (index: number, clientX: number, clientY: number) => { |
| const canvas = canvasRef.current; |
| const current = cardPositions[index]; |
| if (!canvas || !current) { |
| return; |
| } |
|
|
| const rect = canvas.getBoundingClientRect(); |
| const isActive = activeStepIndex === index; |
| const cardSize = isActive ? EXPANDED_CARD : COLLAPSED_CARD; |
| const maxX = Math.max(0, rect.width - cardSize.width); |
| const maxY = Math.max(0, rect.height - cardSize.height); |
| const nextX = Math.min(Math.max(0, clientX), maxX); |
| const nextY = Math.min(Math.max(0, clientY), maxY); |
|
|
| setCardPositions((previous) => |
| previous.map((item, itemIndex) => |
| itemIndex === index |
| ? { |
| ...item, |
| x: rect.width > 0 ? (nextX / rect.width) * 100 : item.x, |
| y: rect.height > 0 ? (nextY / rect.height) * 100 : item.y, |
| } |
| : item |
| ) |
| ); |
| }; |
|
|
| const getRenderedPosition = (index: number, position: CardPosition): CardPosition => { |
| if (activeStepIndex === index) { |
| const centeredX = position.x + (50 - position.x) * ACTIVE_CARD_CENTER_PULL; |
| const centeredY = position.y + (44 - position.y) * ACTIVE_CARD_CENTER_PULL; |
|
|
| return { |
| x: Math.max(2, Math.min(72, centeredX)), |
| y: Math.max(3, Math.min(58, centeredY)), |
| rotate: 0, |
| }; |
| } |
|
|
| if (activeStepIndex === null || activeStepIndex === index) { |
| return position; |
| } |
|
|
| const active = cardPositions[activeStepIndex]; |
| if (!active) { |
| return position; |
| } |
|
|
| const dx = position.x - active.x; |
| const dy = position.y - active.y; |
| const distance = Math.hypot(dx, dy) || 1; |
| const push = ACTIVE_REPEL_DISTANCE / distance; |
|
|
| return { |
| x: Math.max(0, Math.min(88, position.x + dx * push)), |
| y: Math.max(0, Math.min(78, position.y + dy * push)), |
| rotate: position.rotate, |
| }; |
| }; |
|
|
| const handleCardPointerDown = (index: number, event: ReactPointerEvent<HTMLElement>) => { |
| const target = event.target as HTMLElement; |
| if (target.closest('textarea')) { |
| return; |
| } |
|
|
| const canvas = canvasRef.current; |
| const current = cardPositions[index]; |
| if (!canvas || !current) { |
| return; |
| } |
|
|
| const rect = canvas.getBoundingClientRect(); |
| const pointerOriginX = (current.x / 100) * rect.width; |
| const pointerOriginY = (current.y / 100) * rect.height; |
|
|
| dragStateRef.current = { |
| index, |
| pointerId: event.pointerId, |
| startClientX: event.clientX, |
| startClientY: event.clientY, |
| originX: pointerOriginX, |
| originY: pointerOriginY, |
| moved: false, |
| }; |
|
|
| event.currentTarget.setPointerCapture(event.pointerId); |
| }; |
|
|
| const handleCardPointerMove = (event: ReactPointerEvent<HTMLElement>) => { |
| const dragState = dragStateRef.current; |
| if (!dragState || dragState.pointerId !== event.pointerId) { |
| return; |
| } |
|
|
| const deltaX = event.clientX - dragState.startClientX; |
| const deltaY = event.clientY - dragState.startClientY; |
| if (!dragState.moved && Math.abs(deltaX) + Math.abs(deltaY) > 3) { |
| dragState.moved = true; |
| } |
|
|
| updateCardPosition(dragState.index, dragState.originX + deltaX, dragState.originY + deltaY); |
| }; |
|
|
| const handleCardPointerUp = (index: number, event: ReactPointerEvent<HTMLElement>) => { |
| const dragState = dragStateRef.current; |
| if (!dragState || dragState.pointerId !== event.pointerId) { |
| return; |
| } |
|
|
| if (!dragState.moved) { |
| setActiveStepIndex((current) => (current === index ? null : index)); |
| } |
|
|
| if (event.currentTarget.hasPointerCapture(event.pointerId)) { |
| event.currentTarget.releasePointerCapture(event.pointerId); |
| } |
| dragStateRef.current = null; |
| }; |
|
|
| return ( |
| <div className="fixed inset-0 z-[80]"> |
| <div className="animate-overlay-wash-in absolute inset-0 bg-bg-primary/28 backdrop-blur-[6px]" /> |
| |
| <div className="relative flex min-h-screen items-center justify-center p-4 sm:p-8"> |
| <div className="animate-fade-in-soft relative flex h-[min(82vh,760px)] w-full max-w-5xl flex-col overflow-hidden rounded-[2rem] border border-bg-tertiary/30 bg-bg-secondary"> |
| <div className="absolute inset-0 opacity-35 bg-[radial-gradient(circle_at_1px_1px,rgba(0,0,0,0.05)_1px,transparent_0)] bg-[length:24px_24px]" /> |
| <WaitingPaws /> |
| |
| <div className="relative flex flex-1 flex-col px-5 pb-5 pt-6 sm:px-8 sm:pb-6 sm:pt-7"> |
| <div className="flex items-start justify-between gap-4 pb-2"> |
| <button |
| type="button" |
| onClick={() => setConfirmDiscardOpen(true)} |
| className="inline-flex items-center gap-2 px-1 py-1 text-sm text-text-secondary transition-colors hover:text-text-primary" |
| > |
| <span aria-hidden="true">←</span> |
| {t('problem.back')} |
| </button> |
| </div> |
| |
| <div className="mt-2 flex min-h-0 flex-1 flex-col overflow-hidden"> |
| <section className="min-h-0 flex-1 overflow-hidden px-4 pb-2 pt-2 sm:px-6 sm:pb-3 sm:pt-3"> |
| <div className="mx-auto h-full max-w-5xl"> |
| <div className="h-full"> |
| {status === 'loading' && ( |
| <div ref={canvasRef} className="relative h-full min-h-[470px] overflow-hidden"> |
| <CanvasTransition /> |
| <p className="absolute right-0 top-0 text-sm text-text-secondary">{t(statusKey)}</p> |
| <svg className="pointer-events-none absolute inset-0 h-full w-full opacity-35" viewBox="0 0 1100 620" preserveAspectRatio="none"> |
| <path d="M 40 220 C 180 90, 280 90, 370 230 S 560 420, 670 250 S 860 90, 1060 250" fill="none" stroke="currentColor" strokeWidth="1.2" strokeDasharray="6 8" className="text-text-secondary/35" /> |
| </svg> |
| {Array.from({ length: stepCount }, (_, index) => { |
| const layout = CARD_LAYOUTS[index] || CARD_LAYOUTS[CARD_LAYOUTS.length - 1]; |
| return ( |
| <div |
| key={index} |
| className="absolute h-[86px] w-[120px] rounded-[1.2rem] border border-dashed border-text-secondary/35 bg-bg-primary/76 p-4" |
| style={{ |
| left: `${layout.x}%`, |
| top: `${layout.y}%`, |
| transform: `rotate(${layout.rotate}deg)`, |
| animation: `fadeInUp 0.38s ease-out ${0.28 + index * 0.16}s both` |
| }} |
| > |
| <div className="h-4 w-14 animate-pulse rounded-full bg-bg-secondary/80" /> |
| <div className="mt-3 h-3 w-full animate-pulse rounded-full bg-bg-secondary/70" /> |
| <div className="mt-2 h-3 w-4/5 animate-pulse rounded-full bg-bg-secondary/60" /> |
| </div> |
| ); |
| })} |
| </div> |
| )} |
| |
| {status !== 'loading' && plan && ( |
| <div ref={canvasRef} className="relative h-full min-h-[470px] overflow-hidden"> |
| <p className="absolute right-0 top-0 text-sm text-text-secondary">{t(statusKey)}</p> |
| <svg className="pointer-events-none absolute inset-0 h-full w-full opacity-35" viewBox="0 0 1100 620" preserveAspectRatio="none"> |
| <path d="M 40 220 C 180 90, 280 90, 370 230 S 560 420, 670 250 S 860 90, 1060 250" fill="none" stroke="currentColor" strokeWidth="1.2" strokeDasharray="6 8" className="text-text-secondary/35" /> |
| </svg> |
| |
| {draftSteps.map((step, index) => { |
| const isActive = index === activeStepIndex; |
| const position = cardPositions[index] || CARD_LAYOUTS[index] || CARD_LAYOUTS[CARD_LAYOUTS.length - 1]; |
| const renderedPosition = getRenderedPosition(index, position); |
| const collapsedTitle = step.title.slice(0, 6); |
| |
| return ( |
| <article |
| key={`${step.title}-${index}`} |
| onPointerDown={(event) => handleCardPointerDown(index, event)} |
| onPointerMove={handleCardPointerMove} |
| onPointerUp={(event) => handleCardPointerUp(index, event)} |
| onPointerCancel={(event) => handleCardPointerUp(index, event)} |
| className={`absolute rounded-[1.2rem] border border-dashed bg-bg-primary/78 p-4 transition-all duration-500 ease-out ${ |
| isActive |
| ? 'z-20 h-[236px] w-[272px] border-accent/45' |
| : 'z-10 h-[86px] w-[120px] border-text-secondary/35 cursor-grab select-none' |
| }`} |
| style={{ |
| left: `${renderedPosition.x}%`, |
| top: `${renderedPosition.y}%`, |
| transform: isActive ? 'rotate(0deg)' : `rotate(${position.rotate}deg)`, |
| animation: `fadeInUp 0.32s ease-out ${index * 0.12}s both` |
| }} |
| > |
| <p className="pointer-events-none text-[11px] uppercase tracking-[0.24em] text-text-secondary/55"> |
| {String(index + 1).padStart(2, '0')} |
| </p> |
| <h3 className="pointer-events-none mt-1 text-sm leading-5 text-text-primary"> |
| {isActive ? step.title : collapsedTitle} |
| </h3> |
| |
| {isActive ? ( |
| <textarea |
| value={step.content} |
| onChange={(event) => { |
| const nextSteps = draftSteps.map((item, itemIndex) => |
| itemIndex === index ? { ...item, content: event.target.value } : item |
| ); |
| setDraftSteps(nextSteps); |
| }} |
| rows={7} |
| spellCheck={false} |
| className="mt-3 min-h-[140px] w-full resize-none bg-transparent text-sm leading-6 text-text-secondary outline-none overflow-hidden" |
| /> |
| ) : null} |
| </article> |
| ); |
| })} |
| |
| {status === 'error' && ( |
| <div className="absolute bottom-0 left-0 text-sm leading-6 text-red-700"> |
| {error || t('generation.problemFramingFailed')} |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| </section> |
| |
| <aside className="px-5 pb-5 pt-1 sm:px-7 sm:pb-6"> |
| <div className="mx-auto grid max-w-[860px] gap-4 lg:grid-cols-[minmax(0,520px)_auto] lg:items-center lg:translate-x-8"> |
| <div className="lg:translate-y-3"> |
| <textarea |
| value={adjustment} |
| onChange={(event) => onAdjustmentChange(event.target.value)} |
| rows={2} |
| placeholder={t('problem.adjustPlaceholder')} |
| className="mt-3 min-h-[68px] w-full resize-none rounded-2xl bg-bg-secondary/50 px-4 py-3 text-sm leading-6 text-text-primary placeholder-text-secondary/40 outline-none transition-all focus:bg-bg-secondary/70 focus:ring-2 focus:ring-accent/20" |
| /> |
| </div> |
| |
| <div className="flex items-center justify-center gap-5 lg:justify-start lg:translate-x-3 lg:translate-y-3"> |
| <button |
| type="button" |
| onClick={onRetry} |
| disabled={status === 'loading' || !adjustment.trim()} |
| className="px-6 py-3.5 text-sm font-medium text-text-secondary hover:text-text-primary bg-bg-secondary/50 hover:bg-bg-secondary/70 rounded-2xl transition-all disabled:cursor-not-allowed disabled:opacity-45 focus:outline-none focus:ring-2 focus:ring-accent/20" |
| > |
| {t('problem.adjustAction')} |
| </button> |
| <button |
| type="button" |
| onClick={onGenerate} |
| disabled={!plan || status === 'loading' || generating} |
| className="group relative px-5 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-full shadow-sm shadow-accent/10 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed hover:shadow-md hover:shadow-accent/15 active:scale-[0.97] overflow-hidden" |
| > |
| <span className="relative z-10 flex items-center gap-2"> |
| {generating ? ( |
| <> |
| <svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"> |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> |
| </svg> |
| {t('form.submitting')} |
| </> |
| ) : ( |
| <> |
| {t('problem.start')} |
| <svg className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" /> |
| </svg> |
| </> |
| )} |
| </span> |
| <div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:animate-shimmer" /> |
| </button> |
| </div> |
| </div> |
| </aside> |
| </div> |
| </div> |
| |
| {confirmDiscardOpen && ( |
| <div className="absolute inset-0 z-50 flex items-center justify-center p-4"> |
| <div |
| className="absolute inset-0 bg-black/50 backdrop-blur-sm" |
| onClick={() => setConfirmDiscardOpen(false)} |
| /> |
| <div className="relative w-full max-w-sm rounded-2xl border border-bg-tertiary/30 bg-bg-secondary p-6 shadow-xl"> |
| <div className="flex items-start justify-between gap-3"> |
| <div> |
| <h3 className="text-base font-medium text-text-primary">{t('problem.discardTitle')}</h3> |
| <p className="mt-2 text-sm leading-relaxed text-text-secondary">{t('problem.discardDescription')}</p> |
| </div> |
| <button |
| type="button" |
| onClick={() => setConfirmDiscardOpen(false)} |
| className="rounded-full p-1.5 text-text-secondary/70 transition-all hover:bg-bg-primary/50 hover:text-text-secondary" |
| aria-label={t('common.close')} |
| title={t('common.close')} |
| > |
| <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> |
| </svg> |
| </button> |
| </div> |
| <div className="mt-6 flex items-center justify-end gap-3"> |
| <button |
| type="button" |
| onClick={() => setConfirmDiscardOpen(false)} |
| className="rounded-xl bg-bg-primary px-4 py-2 text-sm text-text-secondary transition-all hover:bg-bg-tertiary hover:text-text-primary" |
| > |
| {t('common.cancel')} |
| </button> |
| <button |
| type="button" |
| onClick={() => { |
| setConfirmDiscardOpen(false); |
| onClose(); |
| }} |
| className="rounded-xl bg-red-500 px-4 py-2 text-sm font-medium text-bg-primary transition-all hover:bg-red-600" |
| > |
| {t('common.confirm')} |
| </button> |
| </div> |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|