@jsonpages/cli 3.0.46 → 3.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -294,22 +294,593 @@ export const NotFound: React.FC = () => {
294
294
 
295
295
  END_OF_FILE_CONTENT
296
296
  mkdir -p "src/components/SaveDrawer"
297
+ echo "Creating src/components/SaveDrawer/DeployConnector.tsx..."
298
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/DeployConnector.tsx"
299
+ import type { StepState } from './types';
300
+
301
+ interface DeployConnectorProps {
302
+ fromState: StepState;
303
+ toState: StepState;
304
+ color: string;
305
+ }
306
+
307
+ export function DeployConnector({ fromState, toState, color }: DeployConnectorProps) {
308
+ const filled = fromState === 'done' && toState === 'done';
309
+ const filling = fromState === 'done' && toState === 'active';
310
+ const lit = filled || filling;
311
+
312
+ return (
313
+ <div className="relative flex-1 z-0" style={{ height: 2, marginTop: -24 }}>
314
+ <div className="absolute inset-0 bg-white/8 rounded-full" />
315
+
316
+ <div
317
+ className="absolute top-0 left-0 bottom-0 rounded-full"
318
+ style={{
319
+ background: `linear-gradient(90deg, ${color}cc, ${color}66)`,
320
+ width: filled ? '100%' : filling ? '100%' : '0%',
321
+ transition: filling ? 'width 2s cubic-bezier(0.4,0,0.2,1)' : 'none',
322
+ boxShadow: lit ? `0 0 8px ${color}77` : 'none',
323
+ }}
324
+ />
325
+
326
+ {filling && (
327
+ <div
328
+ className="absolute rounded-full"
329
+ style={{
330
+ top: '50%',
331
+ transform: 'translateY(-50%)',
332
+ width: 10,
333
+ height: 10,
334
+ background: color,
335
+ boxShadow: `0 0 14px ${color}, 0 0 28px ${color}88`,
336
+ animation: 'save-orb-travel 2s cubic-bezier(0.4,0,0.6,1) forwards',
337
+ }}
338
+ />
339
+ )}
340
+ </div>
341
+ );
342
+ }
343
+
344
+ END_OF_FILE_CONTENT
345
+ echo "Creating src/components/SaveDrawer/DeployNode.tsx..."
346
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/DeployNode.tsx"
347
+ import type { DeployStep, StepState } from './types';
348
+ import { cn } from '@/lib/utils';
349
+
350
+ interface DeployNodeProps {
351
+ step: DeployStep;
352
+ state: StepState;
353
+ }
354
+
355
+ export function DeployNode({ step, state }: DeployNodeProps) {
356
+ const isActive = state === 'active';
357
+ const isDone = state === 'done';
358
+ const isPending = state === 'pending';
359
+
360
+ return (
361
+ <div className="flex flex-col items-center gap-2.5 relative z-10">
362
+ <div
363
+ className={cn(
364
+ 'relative flex h-12 w-12 items-center justify-center rounded-full border transition-all duration-500',
365
+ isPending && 'border-white/8 bg-white/[0.02]',
366
+ isActive && 'border',
367
+ isDone && 'border-0'
368
+ )}
369
+ style={{
370
+ background: isDone ? step.color : isActive ? 'rgba(0,0,0,0.5)' : undefined,
371
+ borderColor: isActive ? `${step.color}80` : undefined,
372
+ boxShadow: isDone
373
+ ? `0 0 20px ${step.color}55, 0 0 40px ${step.color}22`
374
+ : isActive
375
+ ? `0 0 14px ${step.color}33`
376
+ : undefined,
377
+ animation: isActive ? 'save-node-glow 2s ease infinite' : undefined,
378
+ ['--glow-color' as string]: step.color,
379
+ }}
380
+ >
381
+ {isDone && (
382
+ <svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" aria-label="Done">
383
+ <path
384
+ className="stroke-dash-30 save-check-draw"
385
+ d="M5 13l4 4L19 7"
386
+ stroke="#0a0f1a"
387
+ strokeWidth="2.5"
388
+ strokeLinecap="round"
389
+ strokeLinejoin="round"
390
+ />
391
+ </svg>
392
+ )}
393
+
394
+ {isActive && (
395
+ <span
396
+ className="text-lg leading-none"
397
+ style={{
398
+ color: step.color,
399
+ animation: 'save-glyph-rotate 9s linear infinite',
400
+ display: 'inline-block',
401
+ }}
402
+ aria-hidden
403
+ >
404
+ {step.glyph}
405
+ </span>
406
+ )}
407
+
408
+ {isPending && (
409
+ <span className="text-lg leading-none text-white/15" aria-hidden>
410
+ {step.glyph}
411
+ </span>
412
+ )}
413
+
414
+ {isActive && (
415
+ <span
416
+ className="absolute rounded-full border"
417
+ style={{
418
+ inset: -7,
419
+ borderColor: `${step.color}50`,
420
+ animation: 'save-ring-expand 2s ease-out infinite',
421
+ }}
422
+ />
423
+ )}
424
+ </div>
425
+
426
+ <span
427
+ className="text-[10px] font-semibold uppercase tracking-[0.1em] transition-colors duration-500"
428
+ style={{
429
+ color: isDone ? step.color : isActive ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.18)',
430
+ }}
431
+ >
432
+ {step.label}
433
+ </span>
434
+ </div>
435
+ );
436
+ }
437
+
438
+ END_OF_FILE_CONTENT
297
439
  echo "Creating src/components/SaveDrawer/SaveDrawer.tsx..."
298
440
  cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/SaveDrawer.tsx"
299
441
  /**
300
- * SaveDrawer — Cloud save flow with real SSE (commit → push → build → live).
301
- * Opens when user saves in Cloud mode; consumes POST save-stream and shows
302
- * the four steps. On done: "Refresh page" + "Close" only (no redirect).
442
+ * SaveDrawer — Identical to prototype DopaDrawer.
443
+ * Bottom sheet for Cloud save; driven by real SSE (save-stream).
444
+ * Done: "Refresh page" + "Chiudi". Error: "Annulla" + "Retry".
303
445
  */
304
- import { useState, useEffect, useCallback } from 'react';
446
+ import { useState } from 'react';
447
+ import { cn } from '@/lib/utils';
448
+ import { Separator } from '@/components/ui/separator';
449
+ import { DeployNode } from './DeployNode';
450
+ import { DeployConnector } from './DeployConnector';
451
+ import { Particles, BuildBars, SuccessBurst, ElapsedTimer } from './Visuals';
452
+ import { useSaveStream } from './useSaveStream';
453
+ import { DEPLOY_STEPS } from './steps';
305
454
  import type { ProjectState } from '@jsonpages/core';
306
455
 
307
- const SAVE_STEPS = [
308
- { id: 'commit' as const, label: 'Commit' },
309
- { id: 'push' as const, label: 'Push' },
310
- { id: 'build' as const, label: 'Build' },
311
- { id: 'live' as const, label: 'Live' },
312
- ];
456
+ export interface SaveDrawerProps {
457
+ cloudUrl: string;
458
+ apiKey: string;
459
+ state: ProjectState;
460
+ slug: string;
461
+ onClose: () => void;
462
+ }
463
+
464
+ export function SaveDrawer({ cloudUrl, apiKey, state, slug, onClose }: SaveDrawerProps) {
465
+ const [burst, setBurst] = useState(false);
466
+
467
+ const { phase, currentStep, stepState, doneSteps, progress, retry } = useSaveStream({
468
+ cloudUrl,
469
+ apiKey,
470
+ state,
471
+ slug,
472
+ onClose,
473
+ });
474
+
475
+ const isRunning = phase === 'running';
476
+ const isDone = phase === 'done';
477
+ const isError = phase === 'error';
478
+
479
+ if (isDone && !burst) setBurst(true);
480
+
481
+ const activeColor = isDone
482
+ ? '#34d399'
483
+ : isError
484
+ ? '#f87171'
485
+ : (currentStep?.color ?? '#60a5fa');
486
+
487
+ const particleCount =
488
+ isDone ? 40
489
+ : doneSteps.length === 3 ? 28
490
+ : doneSteps.length === 2 ? 16
491
+ : doneSteps.length === 1 ? 8
492
+ : 4;
493
+
494
+ return (
495
+ <>
496
+ <div
497
+ className="fixed inset-0 bg-black/40 backdrop-blur-[2px] z-40 save-fade-in"
498
+ onClick={isDone || isError ? onClose : undefined}
499
+ aria-hidden
500
+ />
501
+
502
+ <div
503
+ role="status"
504
+ aria-live="polite"
505
+ aria-label={isDone ? 'Deploy completed' : isError ? 'Deploy failed' : 'Deploying'}
506
+ className="fixed inset-x-0 bottom-0 z-50 flex justify-center pb-6 px-4 save-drawer-up"
507
+ >
508
+ <div
509
+ className={cn(
510
+ 'relative w-full max-w-2xl overflow-hidden rounded-2xl',
511
+ 'border border-white/[0.07] bg-[hsl(222,18%,7%)]'
512
+ )}
513
+ style={{
514
+ boxShadow: `0 0 0 1px rgba(255,255,255,0.04), 0 -20px 60px rgba(0,0,0,0.6), 0 0 80px ${activeColor}0d`,
515
+ transition: 'box-shadow 1.2s ease',
516
+ }}
517
+ >
518
+ <div
519
+ className="pointer-events-none absolute inset-0"
520
+ style={{
521
+ background: `radial-gradient(ellipse 70% 60% at 50% 110%, ${activeColor}12 0%, transparent 65%)`,
522
+ transition: 'background 1.5s ease',
523
+ animation: 'save-ambient-pulse 3.5s ease infinite',
524
+ }}
525
+ aria-hidden
526
+ />
527
+
528
+ {isDone && (
529
+ <div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
530
+ <div
531
+ className="absolute inset-y-0 w-[35%]"
532
+ style={{
533
+ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent)',
534
+ animation: 'save-shimmer-sweep 1.4s 0.1s ease forwards',
535
+ }}
536
+ />
537
+ </div>
538
+ )}
539
+
540
+ <Particles count={particleCount} color={activeColor} />
541
+ {burst && <SuccessBurst />}
542
+
543
+ <div className="relative z-10 px-8 pt-7 pb-6">
544
+ <div className="flex items-start justify-between mb-7">
545
+ <div className="flex flex-col gap-2.5">
546
+ <div
547
+ className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-[0.1em] transition-colors duration-500"
548
+ style={{ color: activeColor }}
549
+ >
550
+ <span
551
+ className="inline-block h-1.5 w-1.5 rounded-full"
552
+ style={{
553
+ background: activeColor,
554
+ boxShadow: `0 0 6px ${activeColor}`,
555
+ animation: isRunning ? 'save-ambient-pulse 1.5s ease infinite' : 'none',
556
+ }}
557
+ aria-hidden
558
+ />
559
+ {isDone ? 'Live' : isError ? 'Build failed' : currentStep?.verb ?? 'Saving'}
560
+ </div>
561
+
562
+ <div key={currentStep?.id ?? phase} className="save-text-in min-h-[42px]">
563
+ {isDone ? (
564
+ <div className="save-success-pop">
565
+ <p className="text-lg font-semibold text-white leading-snug">Your content is live.</p>
566
+ <p className="text-sm text-white/40 mt-0.5">Deployed to production successfully</p>
567
+ </div>
568
+ ) : isError ? (
569
+ <>
570
+ <p className="text-base font-semibold text-white leading-snug">Deploy failed at build.</p>
571
+ <p className="text-sm text-white/35 mt-0.5">Check your Vercel logs or retry below</p>
572
+ </>
573
+ ) : currentStep ? (
574
+ <>
575
+ <p className="text-sm font-light text-white/55 leading-relaxed">{currentStep.poem[0]}</p>
576
+ <p className="text-sm font-light text-white/30 leading-relaxed">{currentStep.poem[1]}</p>
577
+ </>
578
+ ) : null}
579
+ </div>
580
+ </div>
581
+
582
+ <div className="flex flex-col items-end gap-1.5 shrink-0 ml-6">
583
+ {!isDone && <ElapsedTimer running={isRunning} />}
584
+ </div>
585
+ </div>
586
+
587
+ <div className="flex items-center mb-5">
588
+ {DEPLOY_STEPS.map((step, i) => (
589
+ <div
590
+ key={step.id}
591
+ className={cn('flex items-center', i < DEPLOY_STEPS.length - 1 ? 'flex-1' : 'flex-none')}
592
+ >
593
+ <DeployNode step={step} state={stepState(i)} />
594
+ {i < DEPLOY_STEPS.length - 1 && (
595
+ <DeployConnector
596
+ fromState={stepState(i)}
597
+ toState={stepState(i + 1)}
598
+ color={DEPLOY_STEPS[i + 1].color}
599
+ />
600
+ )}
601
+ </div>
602
+ ))}
603
+ </div>
604
+
605
+ <div className="flex justify-center mb-5">
606
+ <BuildBars active={stepState(2) === 'active'} />
607
+ </div>
608
+
609
+ <Separator className="mb-5 bg-white/[0.06]" />
610
+
611
+ <div className="flex items-center gap-4">
612
+ <div className="flex-1 h-[2px] bg-white/[0.06] rounded-full overflow-hidden">
613
+ <div
614
+ className="h-full rounded-full transition-all duration-300"
615
+ style={{
616
+ width: `${progress}%`,
617
+ background: `linear-gradient(90deg, ${DEPLOY_STEPS[0].color}, ${activeColor})`,
618
+ }}
619
+ />
620
+ </div>
621
+
622
+ <div className="flex items-center gap-2 shrink-0">
623
+ {isDone && (
624
+ <div className="flex gap-2 save-fade-up">
625
+ <button
626
+ type="button"
627
+ onClick={() => window.location.reload()}
628
+ className="px-3 py-1.5 rounded-md bg-emerald-400 text-zinc-900 text-sm font-semibold hover:bg-emerald-300"
629
+ >
630
+ Refresh page
631
+ </button>
632
+ <button
633
+ type="button"
634
+ onClick={onClose}
635
+ className="px-3 py-1.5 rounded-md border border-white/20 text-white/90 text-sm hover:bg-white/10"
636
+ >
637
+ Chiudi
638
+ </button>
639
+ </div>
640
+ )}
641
+
642
+ {isError && (
643
+ <div className="flex gap-2 save-fade-up">
644
+ <button
645
+ type="button"
646
+ onClick={onClose}
647
+ className="px-3 py-1.5 rounded-md text-white/80 text-sm hover:bg-white/10"
648
+ >
649
+ Annulla
650
+ </button>
651
+ <button
652
+ type="button"
653
+ onClick={retry}
654
+ className="px-3 py-1.5 rounded-md bg-red-500/90 text-white text-sm font-medium hover:bg-red-500"
655
+ >
656
+ Retry
657
+ </button>
658
+ </div>
659
+ )}
660
+
661
+ {isRunning && (
662
+ <span className="font-mono text-xs text-white/20" aria-hidden>
663
+ {doneSteps.length + 1} / {DEPLOY_STEPS.length}
664
+ </span>
665
+ )}
666
+ </div>
667
+ </div>
668
+ </div>
669
+ </div>
670
+ </div>
671
+ </>
672
+ );
673
+ }
674
+
675
+ END_OF_FILE_CONTENT
676
+ echo "Creating src/components/SaveDrawer/Visuals.tsx..."
677
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/Visuals.tsx"
678
+ import { useRef, useState, useEffect } from 'react';
679
+
680
+ const POOL: { id: number; x: number; y: number; size: number; dur: number; delay: number }[] = Array.from(
681
+ { length: 44 },
682
+ (_, i) => ({
683
+ id: i,
684
+ x: 5 + Math.random() * 90,
685
+ y: 15 + Math.random() * 70,
686
+ size: 1.5 + Math.random() * 2.5,
687
+ dur: 2.8 + Math.random() * 3.5,
688
+ delay: Math.random() * 4,
689
+ })
690
+ );
691
+
692
+ interface ParticlesProps {
693
+ count: number;
694
+ color: string;
695
+ }
696
+
697
+ export function Particles({ count, color }: ParticlesProps) {
698
+ return (
699
+ <div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
700
+ {POOL.slice(0, count).map((p) => (
701
+ <div
702
+ key={p.id}
703
+ className="absolute rounded-full"
704
+ style={{
705
+ left: `${p.x}%`,
706
+ bottom: `${p.y}%`,
707
+ width: p.size,
708
+ height: p.size,
709
+ background: color,
710
+ boxShadow: `0 0 ${p.size * 3}px ${color}`,
711
+ opacity: 0,
712
+ animation: `save-particle-float ${p.dur}s ${p.delay}s ease-out infinite`,
713
+ }}
714
+ />
715
+ ))}
716
+ </div>
717
+ );
718
+ }
719
+
720
+ const BAR_H = [0.45, 0.75, 0.55, 0.9, 0.65, 0.8, 0.5, 0.72, 0.6, 0.85, 0.42, 0.7];
721
+
722
+ interface BuildBarsProps {
723
+ active: boolean;
724
+ }
725
+
726
+ export function BuildBars({ active }: BuildBarsProps) {
727
+ if (!active) return <div className="h-7" />;
728
+ return (
729
+ <div className="flex items-end gap-[3px] h-7" aria-hidden>
730
+ {BAR_H.map((h, i) => (
731
+ <div
732
+ key={i}
733
+ className="w-[3px] rounded-sm"
734
+ style={{
735
+ background: '#f59e0b',
736
+ height: `${h * 100}%`,
737
+ transformOrigin: 'bottom',
738
+ animation: `save-bar-eq ${0.42 + i * 0.06}s ${i * 0.04}s ease-in-out infinite alternate`,
739
+ }}
740
+ />
741
+ ))}
742
+ </div>
743
+ );
744
+ }
745
+
746
+ const BURST_COLORS = ['#34d399', '#60a5fa', '#a78bfa', '#f59e0b', '#f472b6'];
747
+
748
+ export function SuccessBurst() {
749
+ return (
750
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center" aria-hidden>
751
+ {Array.from({ length: 16 }).map((_, i) => (
752
+ <div
753
+ key={i}
754
+ className="absolute rounded-full"
755
+ style={{
756
+ width: 5,
757
+ height: 5,
758
+ background: BURST_COLORS[i % BURST_COLORS.length],
759
+ ['--r' as string]: `${i * 22.5}deg`,
760
+ animation: `save-burst-ray 0.85s ${i * 0.03}s cubic-bezier(0,0.6,0.5,1) forwards`,
761
+ transform: `rotate(${i * 22.5}deg)`,
762
+ transformOrigin: '50% 50%',
763
+ opacity: 0,
764
+ }}
765
+ />
766
+ ))}
767
+ </div>
768
+ );
769
+ }
770
+
771
+ interface ElapsedTimerProps {
772
+ running: boolean;
773
+ }
774
+
775
+ export function ElapsedTimer({ running }: ElapsedTimerProps) {
776
+ const [elapsed, setElapsed] = useState(0);
777
+ const startRef = useRef<number | null>(null);
778
+ const rafRef = useRef<number | null>(null);
779
+
780
+ useEffect(() => {
781
+ if (!running) return;
782
+ if (!startRef.current) startRef.current = performance.now();
783
+
784
+ const tick = (): void => {
785
+ setElapsed(Math.floor((performance.now() - (startRef.current ?? 0)) / 1000));
786
+ rafRef.current = requestAnimationFrame(tick);
787
+ };
788
+ rafRef.current = requestAnimationFrame(tick);
789
+ return () => {
790
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
791
+ };
792
+ }, [running]);
793
+
794
+ const s = String(elapsed % 60).padStart(2, '0');
795
+ const m = String(Math.floor(elapsed / 60)).padStart(2, '0');
796
+
797
+ return (
798
+ <span className="font-mono text-xs text-white/25 tracking-widest" aria-live="off">
799
+ {m}:{s}
800
+ </span>
801
+ );
802
+ }
803
+
804
+ END_OF_FILE_CONTENT
805
+ echo "Creating src/components/SaveDrawer/index.ts..."
806
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/index.ts"
807
+ export { SaveDrawer } from './SaveDrawer';
808
+
809
+ END_OF_FILE_CONTENT
810
+ echo "Creating src/components/SaveDrawer/steps.ts..."
811
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/steps.ts"
812
+ import type { DeployStep } from './types';
813
+
814
+ export const DEPLOY_STEPS: readonly DeployStep[] = [
815
+ {
816
+ id: 'commit',
817
+ label: 'Commit',
818
+ verb: 'Committing',
819
+ poem: ['Crystallizing your edit', 'into permanent history.'],
820
+ color: '#60a5fa',
821
+ glyph: '◈',
822
+ duration: 2200,
823
+ },
824
+ {
825
+ id: 'push',
826
+ label: 'Push',
827
+ verb: 'Pushing',
828
+ poem: ['Sending your vision', 'across the wire.'],
829
+ color: '#a78bfa',
830
+ glyph: '◎',
831
+ duration: 2800,
832
+ },
833
+ {
834
+ id: 'build',
835
+ label: 'Build',
836
+ verb: 'Building',
837
+ poem: ['Assembling the pieces,', 'brick by digital brick.'],
838
+ color: '#f59e0b',
839
+ glyph: '⬡',
840
+ duration: 7500,
841
+ },
842
+ {
843
+ id: 'live',
844
+ label: 'Live',
845
+ verb: 'Going live',
846
+ poem: ['Your content', 'is now breathing.'],
847
+ color: '#34d399',
848
+ glyph: '✦',
849
+ duration: 1600,
850
+ },
851
+ ] as const;
852
+
853
+ END_OF_FILE_CONTENT
854
+ echo "Creating src/components/SaveDrawer/types.ts..."
855
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/types.ts"
856
+ /** Save/deploy flow types (aligned with prototype) */
857
+
858
+ export type StepId = 'commit' | 'push' | 'build' | 'live';
859
+
860
+ export type StepState = 'pending' | 'active' | 'done';
861
+
862
+ export type DeployPhase = 'running' | 'done' | 'error';
863
+
864
+ export interface DeployStep {
865
+ id: StepId;
866
+ label: string;
867
+ verb: string;
868
+ poem: [string, string];
869
+ color: string;
870
+ glyph: string;
871
+ duration: number;
872
+ }
873
+
874
+ END_OF_FILE_CONTENT
875
+ echo "Creating src/components/SaveDrawer/useSaveStream.ts..."
876
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/useSaveStream.ts"
877
+ /**
878
+ * useSaveStream — Same API as prototype useSaveOrchestrator, but driven by real SSE from save-stream.
879
+ */
880
+ import { useState, useCallback, useEffect, useRef } from 'react';
881
+ import type { ProjectState } from '@jsonpages/core';
882
+ import type { DeployPhase, StepId, StepState } from './types';
883
+ import { DEPLOY_STEPS } from './steps';
313
884
 
314
885
  function parseSSE(buffer: string): { event: string; data: string }[] {
315
886
  const lines = buffer.split('\n');
@@ -329,7 +900,7 @@ function parseSSE(buffer: string): { event: string; data: string }[] {
329
900
  return out;
330
901
  }
331
902
 
332
- export interface SaveDrawerProps {
903
+ interface UseSaveStreamOptions {
333
904
  cloudUrl: string;
334
905
  apiKey: string;
335
906
  state: ProjectState;
@@ -337,23 +908,44 @@ export interface SaveDrawerProps {
337
908
  onClose: () => void;
338
909
  }
339
910
 
340
- export function SaveDrawer({ cloudUrl, apiKey, state, slug, onClose }: SaveDrawerProps) {
341
- const [completedSteps, setCompletedSteps] = useState<string[]>([]);
342
- const [activeStepId, setActiveStepId] = useState<string | null>('commit');
343
- const [logs, setLogs] = useState<Record<string, string[]>>({});
344
- const [phase, setPhase] = useState<'running' | 'done' | 'error'>('running');
345
- const [errorMessage, setErrorMessage] = useState<string | null>(null);
911
+ export function useSaveStream({
912
+ cloudUrl,
913
+ apiKey,
914
+ state,
915
+ slug,
916
+ onClose: _onClose,
917
+ }: UseSaveStreamOptions) {
918
+ const [phase, setPhase] = useState<DeployPhase>('running');
919
+ const [doneSteps, setDoneSteps] = useState<StepId[]>([]);
920
+ const [activeStepId, setActiveStepId] = useState<StepId | null>('commit');
921
+ const [progress, setProgress] = useState(0);
922
+ const progressRef = useRef(0);
923
+ const [runId, setRunId] = useState(0);
924
+
925
+ const stepState = useCallback(
926
+ (i: number): StepState => {
927
+ const step = DEPLOY_STEPS[i];
928
+ if (!step) return 'pending';
929
+ if (doneSteps.includes(step.id)) return 'done';
930
+ if (activeStepId === step.id && phase === 'running') return 'active';
931
+ return 'pending';
932
+ },
933
+ [doneSteps, activeStepId, phase]
934
+ );
346
935
 
347
- const addLog = useCallback((stepId: string, message: string) => {
348
- setLogs((prev) => ({ ...prev, [stepId]: [...(prev[stepId] || []), message] }));
349
- }, []);
936
+ const currentStep = DEPLOY_STEPS.find((s) => s.id === activeStepId) ?? DEPLOY_STEPS[0] ?? null;
350
937
 
351
938
  useEffect(() => {
352
939
  let cancelled = false;
353
- let gotDone = false;
940
+ setPhase('running');
941
+ setDoneSteps([]);
942
+ setActiveStepId('commit');
943
+ setProgress(0);
944
+ progressRef.current = 0;
945
+
946
+ const url = `${cloudUrl.replace(/\/$/, '')}/save-stream`;
354
947
 
355
948
  const run = async () => {
356
- const url = `${cloudUrl.replace(/\/$/, '')}/save-stream`;
357
949
  const res = await fetch(url, {
358
950
  method: 'POST',
359
951
  headers: {
@@ -368,9 +960,7 @@ export function SaveDrawer({ cloudUrl, apiKey, state, slug, onClose }: SaveDrawe
368
960
  });
369
961
 
370
962
  if (!res.ok || !res.body) {
371
- const data = await res.json().catch(() => ({}));
372
- setErrorMessage((data as { error?: string }).error || `Request failed: ${res.status}`);
373
- setPhase('error');
963
+ if (!cancelled) setPhase('error');
374
964
  return;
375
965
  }
376
966
 
@@ -392,179 +982,57 @@ export function SaveDrawer({ cloudUrl, apiKey, state, slug, onClose }: SaveDrawe
392
982
  try {
393
983
  const data = JSON.parse(raw) as Record<string, unknown>;
394
984
  if (ev === 'step') {
395
- const id = data.id as string | undefined;
985
+ const id = data.id as StepId | undefined;
396
986
  const status = data.status as string | undefined;
397
- if (id && status === 'running') setActiveStepId(id);
987
+ if (id && status === 'running') {
988
+ setActiveStepId(id);
989
+ const idx = DEPLOY_STEPS.findIndex((s) => s.id === id);
990
+ if (idx >= 0) progressRef.current = Math.round((idx / DEPLOY_STEPS.length) * 100);
991
+ setProgress(progressRef.current);
992
+ }
398
993
  if (id && status === 'done') {
399
- setCompletedSteps((prev) => [...prev, id].filter(Boolean));
994
+ setDoneSteps((prev) => [...prev, id].filter(Boolean));
400
995
  setActiveStepId(null);
996
+ const idx = DEPLOY_STEPS.findIndex((s) => s.id === id);
997
+ progressRef.current = idx >= 0 ? Math.round(((idx + 1) / DEPLOY_STEPS.length) * 100) : 100;
998
+ setProgress(progressRef.current);
401
999
  }
402
- } else if (ev === 'log' && data.stepId && data.message) {
403
- addLog(data.stepId as string, data.message as string);
404
1000
  } else if (ev === 'error') {
405
- setErrorMessage((data.message as string) || 'Error');
406
1001
  setPhase('error');
407
1002
  return;
408
1003
  } else if (ev === 'done') {
409
- gotDone = true;
410
- setCompletedSteps((prev) => [...new Set([...prev, 'commit', 'push', 'build', 'live'])]);
1004
+ setProgress(100);
1005
+ setDoneSteps(['commit', 'push', 'build', 'live']);
411
1006
  setActiveStepId(null);
412
1007
  setPhase('done');
413
1008
  return;
414
1009
  }
415
1010
  } catch {
416
- // ignore parse errors
1011
+ // ignore
417
1012
  }
418
1013
  }
419
1014
  }
420
1015
  }
421
-
422
- if (!cancelled && !gotDone) {
423
- setErrorMessage('Connection closed before completion');
424
- setPhase('error');
425
- }
426
1016
  };
427
1017
 
428
1018
  run();
429
1019
  return () => {
430
1020
  cancelled = true;
431
1021
  };
432
- }, [cloudUrl, apiKey, state.page, slug, addLog]);
1022
+ }, [cloudUrl, apiKey, slug, state.page, runId]);
433
1023
 
434
- const progressPct =
435
- phase === 'done' ? 100 : SAVE_STEPS.length > 0 ? Math.round((completedSteps.length / SAVE_STEPS.length) * 100) : 0;
1024
+ const retry = useCallback(() => setRunId((r) => r + 1), []);
436
1025
 
437
- const stepState = (id: string) => {
438
- if (completedSteps.includes(id)) return 'done';
439
- if (activeStepId === id) return 'active';
440
- return 'pending';
1026
+ return {
1027
+ phase,
1028
+ currentStep,
1029
+ stepState,
1030
+ doneSteps,
1031
+ progress,
1032
+ retry,
441
1033
  };
442
-
443
- return (
444
- <>
445
- <div
446
- className="fixed inset-0 z-40 bg-black/40 backdrop-blur-[2px]"
447
- onClick={phase === 'done' || phase === 'error' ? onClose : undefined}
448
- aria-hidden
449
- />
450
- <div
451
- role="status"
452
- aria-live="polite"
453
- className="fixed inset-x-0 bottom-0 z-50 flex justify-center pb-6 px-4"
454
- aria-label={phase === 'done' ? 'Save completed' : phase === 'error' ? 'Save failed' : 'Saving'}
455
- >
456
- <div
457
- className="relative w-full max-w-2xl overflow-hidden rounded-2xl border border-white/[0.07] bg-[hsl(222,18%,7%)] shadow-lg"
458
- onClick={(e) => e.stopPropagation()}
459
- >
460
- <div className="relative z-10 px-6 pt-6 pb-5">
461
- <div className="flex items-center justify-between mb-4">
462
- <h2 className="text-lg font-semibold text-white">
463
- {phase === 'done' ? 'Content is live' : phase === 'error' ? 'Save failed' : 'Saving to Cloud'}
464
- </h2>
465
- {phase === 'running' && (
466
- <span className="text-xs font-mono text-white/50">{completedSteps.length} / {SAVE_STEPS.length}</span>
467
- )}
468
- </div>
469
-
470
- {phase === 'error' && errorMessage && (
471
- <p className="text-sm text-red-400 mb-4">{errorMessage}</p>
472
- )}
473
-
474
- <div className="flex items-center gap-2 mb-4">
475
- {SAVE_STEPS.map((step, i) => (
476
- <div key={step.id} className="flex items-center flex-1">
477
- <div
478
- className={`flex items-center justify-center w-8 h-8 rounded-full border-2 shrink-0 ${
479
- stepState(step.id) === 'done'
480
- ? 'bg-emerald-500/20 border-emerald-500'
481
- : stepState(step.id) === 'active'
482
- ? 'bg-blue-500/20 border-blue-500'
483
- : 'bg-white/10 border-white/20'
484
- }`}
485
- >
486
- {stepState(step.id) === 'done' ? (
487
- <span className="text-emerald-400" aria-hidden>✓</span>
488
- ) : stepState(step.id) === 'active' ? (
489
- <span className="w-3 h-3 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" aria-hidden />
490
- ) : (
491
- <span className="text-xs text-white/40">{i + 1}</span>
492
- )}
493
- </div>
494
- <span
495
- className={`ml-2 text-xs font-medium truncate ${
496
- stepState(step.id) !== 'pending' ? 'text-white' : 'text-white/40'
497
- }`}
498
- >
499
- {step.label}
500
- </span>
501
- {i < SAVE_STEPS.length - 1 && (
502
- <div
503
- className={`flex-1 h-0.5 mx-2 min-w-[8px] ${
504
- stepState(step.id) === 'done' ? 'bg-emerald-500/50' : 'bg-white/10'
505
- }`}
506
- />
507
- )}
508
- </div>
509
- ))}
510
- </div>
511
-
512
- <div className="h-1 bg-white/10 rounded-full overflow-hidden mb-4">
513
- <div
514
- className="h-full bg-emerald-500 rounded-full transition-all duration-300"
515
- style={{ width: `${progressPct}%` }}
516
- />
517
- </div>
518
-
519
- {Object.keys(logs).length > 0 && (
520
- <div className="mb-4 p-2 rounded bg-black/30 border border-white/10 font-mono text-xs text-white/60 space-y-0.5 max-h-20 overflow-y-auto">
521
- {(logs[activeStepId || ''] || []).slice(-3).map((line, i) => (
522
- <div key={i}>$ {line}</div>
523
- ))}
524
- </div>
525
- )}
526
-
527
- <div className="flex items-center gap-2 justify-end">
528
- {phase === 'done' && (
529
- <>
530
- <button
531
- type="button"
532
- onClick={() => window.location.reload()}
533
- className="px-4 py-2 rounded-lg bg-emerald-500 text-zinc-900 font-medium text-sm hover:bg-emerald-400"
534
- >
535
- Refresh page
536
- </button>
537
- <button
538
- type="button"
539
- onClick={onClose}
540
- className="px-4 py-2 rounded-lg border border-white/20 text-white/90 text-sm hover:bg-white/10"
541
- >
542
- Close
543
- </button>
544
- </>
545
- )}
546
- {phase === 'error' && (
547
- <button
548
- type="button"
549
- onClick={onClose}
550
- className="px-4 py-2 rounded-lg border border-white/20 text-white/90 text-sm hover:bg-white/10"
551
- >
552
- Close
553
- </button>
554
- )}
555
- </div>
556
- </div>
557
- </div>
558
- </div>
559
- </>
560
- );
561
1034
  }
562
1035
 
563
- END_OF_FILE_CONTENT
564
- echo "Creating src/components/SaveDrawer/index.ts..."
565
- cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/index.ts"
566
- export { SaveDrawer } from './SaveDrawer';
567
-
568
1036
  END_OF_FILE_CONTENT
569
1037
  mkdir -p "src/components/arch-layers"
570
1038
  echo "Creating src/components/arch-layers/View.tsx..."
@@ -4798,6 +5266,79 @@ html {
4798
5266
  opacity: 0;
4799
5267
  }
4800
5268
 
5269
+ /* ==========================================================================
5270
+ SAVE DRAWER (prototype-identical animations)
5271
+ ========================================================================== */
5272
+ @keyframes save-drawer-up {
5273
+ from { transform: translateY(100%); opacity: 0; }
5274
+ to { transform: translateY(0); opacity: 1; }
5275
+ }
5276
+ @keyframes save-fade-in {
5277
+ from { opacity: 0; }
5278
+ to { opacity: 1; }
5279
+ }
5280
+ @keyframes save-fade-up {
5281
+ from { opacity: 0; transform: translateY(8px); }
5282
+ to { opacity: 1; transform: translateY(0); }
5283
+ }
5284
+ @keyframes save-success-pop {
5285
+ 0% { transform: scale(0.88); opacity: 0; }
5286
+ 60% { transform: scale(1.04); }
5287
+ 100% { transform: scale(1); opacity: 1; }
5288
+ }
5289
+ @keyframes save-ring-expand {
5290
+ from { transform: scale(1); opacity: 0.7; }
5291
+ to { transform: scale(2.1); opacity: 0; }
5292
+ }
5293
+ @keyframes save-orb-travel {
5294
+ from { left: 0%; }
5295
+ to { left: calc(100% - 10px); }
5296
+ }
5297
+ @keyframes save-glyph-rotate {
5298
+ from { transform: rotate(0deg); }
5299
+ to { transform: rotate(360deg); }
5300
+ }
5301
+ @keyframes save-particle-float {
5302
+ 0% { transform: translateY(0) scale(1); opacity: 0; }
5303
+ 15% { opacity: 1; }
5304
+ 100% { transform: translateY(-90px) scale(0.3); opacity: 0; }
5305
+ }
5306
+ @keyframes save-bar-eq {
5307
+ from { transform: scaleY(0.4); }
5308
+ to { transform: scaleY(1); }
5309
+ }
5310
+ @keyframes save-ambient-pulse {
5311
+ 0%, 100% { opacity: 0.3; }
5312
+ 50% { opacity: 0.65; }
5313
+ }
5314
+ @keyframes save-shimmer-sweep {
5315
+ from { transform: translateX(-100%); }
5316
+ to { transform: translateX(250%); }
5317
+ }
5318
+ @keyframes save-text-in {
5319
+ from { opacity: 0; transform: translateX(-6px); }
5320
+ to { opacity: 1; transform: translateX(0); }
5321
+ }
5322
+ @keyframes save-node-glow {
5323
+ 0%, 100% { box-shadow: 0 0 12px var(--glow-color, #60a5fa55); }
5324
+ 50% { box-shadow: 0 0 28px var(--glow-color, #60a5fa88), 0 0 48px var(--glow-color, #60a5fa22); }
5325
+ }
5326
+ @keyframes save-burst-ray {
5327
+ 0% { transform: rotate(var(--r, 0deg)) translateX(0); opacity: 1; }
5328
+ 100% { transform: rotate(var(--r, 0deg)) translateX(56px); opacity: 0; }
5329
+ }
5330
+
5331
+ .save-drawer-up { animation: save-drawer-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
5332
+ .save-fade-in { animation: save-fade-in 0.25s ease forwards; }
5333
+ .save-fade-up { animation: save-fade-up 0.35s ease forwards; }
5334
+ .save-success-pop { animation: save-success-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; }
5335
+ .save-text-in { animation: save-text-in 0.3s ease forwards; }
5336
+
5337
+ .stroke-dash-30 { stroke-dasharray: 30; stroke-dashoffset: 30; }
5338
+ .save-check-draw { animation: save-check-draw 0.4s 0.05s ease forwards; }
5339
+ @keyframes save-check-draw {
5340
+ to { stroke-dashoffset: 0; }
5341
+ }
4801
5342
 
4802
5343
 
4803
5344
  END_OF_FILE_CONTENT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonpages/cli",
3
- "version": "3.0.46",
3
+ "version": "3.0.47",
4
4
  "description": "The Sovereign CLI Engine for JsonPages.",
5
5
  "type": "module",
6
6
  "bin": {