@jsonpages/cli 3.0.46 → 3.0.48

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,639 @@ 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 (dopa-save).
443
+ * Bottom sheet for Cloud save; driven by real SSE (save-stream).
444
+ * Done: countdown 3s + "Chiusura in Xs" + Chiudi + Open site → auto-close. 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 { Progress } from '@/components/ui/progress';
450
+ import { DeployNode } from './DeployNode';
451
+ import { DeployConnector } from './DeployConnector';
452
+ import { Particles, BuildBars, SuccessBurst, ElapsedTimer } from './Visuals';
453
+ import { useSaveStream } from './useSaveStream';
454
+ import { DEPLOY_STEPS } from './steps';
305
455
  import type { ProjectState } from '@jsonpages/core';
306
456
 
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
- ];
457
+ export interface SaveDrawerProps {
458
+ cloudUrl: string;
459
+ apiKey: string;
460
+ state: ProjectState;
461
+ slug: string;
462
+ onClose: () => void;
463
+ }
464
+
465
+ export function SaveDrawer({ cloudUrl, apiKey, state, slug, onClose }: SaveDrawerProps) {
466
+ const [burst, setBurst] = useState(false);
467
+
468
+ const {
469
+ phase,
470
+ currentStep,
471
+ stepState,
472
+ doneSteps,
473
+ progress,
474
+ deployUrl,
475
+ countdown,
476
+ logs,
477
+ errorMessage,
478
+ retry,
479
+ } = useSaveStream({
480
+ cloudUrl,
481
+ apiKey,
482
+ state,
483
+ slug,
484
+ onClose,
485
+ });
486
+
487
+ const isRunning = phase === 'running';
488
+ const isDone = phase === 'done';
489
+ const isError = phase === 'error';
490
+
491
+ if (isDone && !burst) setBurst(true);
492
+
493
+ const activeColor = isDone
494
+ ? '#34d399'
495
+ : isError
496
+ ? '#f87171'
497
+ : (currentStep?.color ?? '#60a5fa');
498
+
499
+ const particleCount =
500
+ isDone ? 40
501
+ : doneSteps.length === 3 ? 28
502
+ : doneSteps.length === 2 ? 16
503
+ : doneSteps.length === 1 ? 8
504
+ : 4;
505
+
506
+ return (
507
+ <>
508
+ <div
509
+ className="fixed inset-0 bg-black/40 backdrop-blur-[2px] z-40 save-fade-in"
510
+ onClick={isDone || isError ? onClose : undefined}
511
+ aria-hidden
512
+ />
513
+
514
+ <div
515
+ role="status"
516
+ aria-live="polite"
517
+ aria-label={isDone ? 'Deploy completed' : isError ? 'Deploy failed' : 'Deploying'}
518
+ className="fixed inset-x-0 bottom-0 z-50 flex justify-center pb-6 px-4 save-drawer-up"
519
+ >
520
+ <div
521
+ className={cn(
522
+ 'relative w-full max-w-2xl overflow-hidden rounded-2xl',
523
+ 'border border-white/[0.07] bg-[hsl(222,18%,7%)]'
524
+ )}
525
+ style={{
526
+ 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`,
527
+ transition: 'box-shadow 1.2s ease',
528
+ }}
529
+ >
530
+ <div
531
+ className="pointer-events-none absolute inset-0"
532
+ style={{
533
+ background: `radial-gradient(ellipse 70% 60% at 50% 110%, ${activeColor}12 0%, transparent 65%)`,
534
+ transition: 'background 1.5s ease',
535
+ animation: 'save-ambient-pulse 3.5s ease infinite',
536
+ }}
537
+ aria-hidden
538
+ />
539
+
540
+ {isDone && (
541
+ <div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
542
+ <div
543
+ className="absolute inset-y-0 w-[35%]"
544
+ style={{
545
+ background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.04), transparent)',
546
+ animation: 'save-shimmer-sweep 1.4s 0.1s ease forwards',
547
+ }}
548
+ />
549
+ </div>
550
+ )}
551
+
552
+ <Particles count={particleCount} color={activeColor} />
553
+ {burst && <SuccessBurst />}
554
+
555
+ <div className="relative z-10 px-8 pt-7 pb-6">
556
+ <div className="flex items-start justify-between mb-7">
557
+ <div className="flex flex-col gap-2.5">
558
+ <div
559
+ className="flex items-center gap-2 text-[11px] font-bold uppercase tracking-[0.1em] transition-colors duration-500"
560
+ style={{ color: activeColor }}
561
+ >
562
+ <span
563
+ className="inline-block h-1.5 w-1.5 rounded-full"
564
+ style={{
565
+ background: activeColor,
566
+ boxShadow: `0 0 6px ${activeColor}`,
567
+ animation: isRunning ? 'save-ambient-pulse 1.5s ease infinite' : 'none',
568
+ }}
569
+ aria-hidden
570
+ />
571
+ {isDone ? 'Live' : isError ? 'Build failed' : currentStep?.verb ?? 'Saving'}
572
+ </div>
573
+
574
+ <div key={currentStep?.id ?? phase} className="save-text-in min-h-[42px]">
575
+ {isDone ? (
576
+ <div className="save-success-pop">
577
+ <p className="text-lg font-semibold text-white leading-snug">Your content is live.</p>
578
+ <p className="text-sm text-white/40 mt-0.5">Deployed to production successfully</p>
579
+ </div>
580
+ ) : isError ? (
581
+ <>
582
+ <p className="text-base font-semibold text-white leading-snug">
583
+ Deploy failed at build.
584
+ </p>
585
+ <p className="text-sm text-white/35 mt-0.5">
586
+ {errorMessage ?? 'Check your Vercel logs or retry below'}
587
+ </p>
588
+ </>
589
+ ) : currentStep ? (
590
+ <>
591
+ <p className="text-sm font-light text-white/55 leading-relaxed">{currentStep.poem[0]}</p>
592
+ <p className="text-sm font-light text-white/30 leading-relaxed">{currentStep.poem[1]}</p>
593
+ </>
594
+ ) : null}
595
+ </div>
596
+ </div>
597
+
598
+ <div className="flex flex-col items-end gap-1.5 shrink-0 ml-6">
599
+ {isDone ? (
600
+ <div className="flex flex-col items-end gap-1.5 save-fade-up">
601
+ <span
602
+ className="font-mono text-xs text-emerald-400 font-semibold"
603
+ aria-live="polite"
604
+ >
605
+ Chiusura in {countdown}s
606
+ </span>
607
+ <div className="w-24 h-0.5 bg-white/10 rounded-full overflow-hidden">
608
+ <div
609
+ className="h-full bg-emerald-400 rounded-full countdown-bar"
610
+ style={{ boxShadow: '0 0 6px #34d39988' }}
611
+ />
612
+ </div>
613
+ </div>
614
+ ) : (
615
+ <ElapsedTimer running={isRunning} />
616
+ )}
617
+ </div>
618
+ </div>
619
+
620
+ <div className="flex items-center mb-5">
621
+ {DEPLOY_STEPS.map((step, i) => (
622
+ <div
623
+ key={step.id}
624
+ className={cn('flex items-center', i < DEPLOY_STEPS.length - 1 ? 'flex-1' : 'flex-none')}
625
+ >
626
+ <DeployNode step={step} state={stepState(i)} />
627
+ {i < DEPLOY_STEPS.length - 1 && (
628
+ <DeployConnector
629
+ fromState={stepState(i)}
630
+ toState={stepState(i + 1)}
631
+ color={DEPLOY_STEPS[i + 1].color}
632
+ />
633
+ )}
634
+ </div>
635
+ ))}
636
+ </div>
637
+
638
+ <div className="flex justify-center mb-5">
639
+ <BuildBars active={stepState(2) === 'active'} />
640
+ </div>
641
+
642
+ <Separator className="mb-5 bg-white/[0.06]" />
643
+
644
+ {isRunning && currentStep && (logs[currentStep.id]?.length ?? 0) > 0 && (
645
+ <div className="mb-4 max-h-16 overflow-y-auto rounded-md bg-white/[0.04] px-3 py-2 font-mono text-[11px] text-white/50 leading-relaxed">
646
+ {logs[currentStep.id]?.map((line, i) => (
647
+ <div key={i}>{line}</div>
648
+ ))}
649
+ </div>
650
+ )}
651
+
652
+ <div className="flex items-center gap-4">
653
+ <div className="flex-1">
654
+ <Progress
655
+ value={progress}
656
+ className="h-[2px] bg-white/[0.06]"
657
+ indicatorClassName="transition-none"
658
+ indicatorStyle={{
659
+ background: `linear-gradient(90deg, ${DEPLOY_STEPS[0].color}, ${activeColor})`,
660
+ }}
661
+ />
662
+ </div>
663
+
664
+ <div className="flex items-center gap-2 shrink-0">
665
+ {isDone && (
666
+ <div className="flex gap-2 save-fade-up">
667
+ <button
668
+ type="button"
669
+ onClick={onClose}
670
+ className="px-3 py-1.5 rounded-md border border-white/20 text-white/90 text-sm hover:bg-white/10"
671
+ >
672
+ Chiudi
673
+ </button>
674
+ <button
675
+ type="button"
676
+ onClick={() =>
677
+ deployUrl ? window.open(deployUrl, '_blank') : window.location.reload()
678
+ }
679
+ className="px-3 py-1.5 rounded-md bg-emerald-400 text-zinc-900 text-sm font-semibold hover:bg-emerald-300 gap-1.5 inline-flex items-center"
680
+ >
681
+ <span aria-hidden>↗</span> Open site
682
+ </button>
683
+ </div>
684
+ )}
685
+
686
+ {isError && (
687
+ <div className="flex gap-2 save-fade-up">
688
+ <button
689
+ type="button"
690
+ onClick={onClose}
691
+ className="px-3 py-1.5 rounded-md text-white/80 text-sm hover:bg-white/10"
692
+ >
693
+ Annulla
694
+ </button>
695
+ <button
696
+ type="button"
697
+ onClick={retry}
698
+ className="px-3 py-1.5 rounded-md bg-red-500/90 text-white text-sm font-medium hover:bg-red-500"
699
+ >
700
+ Retry
701
+ </button>
702
+ </div>
703
+ )}
704
+
705
+ {isRunning && (
706
+ <span className="font-mono text-xs text-white/20" aria-hidden>
707
+ {doneSteps.length + 1} / {DEPLOY_STEPS.length}
708
+ </span>
709
+ )}
710
+ </div>
711
+ </div>
712
+ </div>
713
+ </div>
714
+ </div>
715
+ </>
716
+ );
717
+ }
718
+
719
+ END_OF_FILE_CONTENT
720
+ echo "Creating src/components/SaveDrawer/Visuals.tsx..."
721
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/Visuals.tsx"
722
+ import { useRef, useState, useEffect } from 'react';
723
+
724
+ const POOL: { id: number; x: number; y: number; size: number; dur: number; delay: number }[] = Array.from(
725
+ { length: 44 },
726
+ (_, i) => ({
727
+ id: i,
728
+ x: 5 + Math.random() * 90,
729
+ y: 15 + Math.random() * 70,
730
+ size: 1.5 + Math.random() * 2.5,
731
+ dur: 2.8 + Math.random() * 3.5,
732
+ delay: Math.random() * 4,
733
+ })
734
+ );
735
+
736
+ interface ParticlesProps {
737
+ count: number;
738
+ color: string;
739
+ }
740
+
741
+ export function Particles({ count, color }: ParticlesProps) {
742
+ return (
743
+ <div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
744
+ {POOL.slice(0, count).map((p) => (
745
+ <div
746
+ key={p.id}
747
+ className="absolute rounded-full"
748
+ style={{
749
+ left: `${p.x}%`,
750
+ bottom: `${p.y}%`,
751
+ width: p.size,
752
+ height: p.size,
753
+ background: color,
754
+ boxShadow: `0 0 ${p.size * 3}px ${color}`,
755
+ opacity: 0,
756
+ animation: `save-particle-float ${p.dur}s ${p.delay}s ease-out infinite`,
757
+ }}
758
+ />
759
+ ))}
760
+ </div>
761
+ );
762
+ }
763
+
764
+ 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];
765
+
766
+ interface BuildBarsProps {
767
+ active: boolean;
768
+ }
769
+
770
+ export function BuildBars({ active }: BuildBarsProps) {
771
+ if (!active) return <div className="h-7" />;
772
+ return (
773
+ <div className="flex items-end gap-[3px] h-7" aria-hidden>
774
+ {BAR_H.map((h, i) => (
775
+ <div
776
+ key={i}
777
+ className="w-[3px] rounded-sm"
778
+ style={{
779
+ background: '#f59e0b',
780
+ height: `${h * 100}%`,
781
+ transformOrigin: 'bottom',
782
+ animation: `save-bar-eq ${0.42 + i * 0.06}s ${i * 0.04}s ease-in-out infinite alternate`,
783
+ }}
784
+ />
785
+ ))}
786
+ </div>
787
+ );
788
+ }
789
+
790
+ const BURST_COLORS = ['#34d399', '#60a5fa', '#a78bfa', '#f59e0b', '#f472b6'];
791
+
792
+ export function SuccessBurst() {
793
+ return (
794
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center" aria-hidden>
795
+ {Array.from({ length: 16 }).map((_, i) => (
796
+ <div
797
+ key={i}
798
+ className="absolute rounded-full"
799
+ style={{
800
+ width: 5,
801
+ height: 5,
802
+ background: BURST_COLORS[i % BURST_COLORS.length],
803
+ ['--r' as string]: `${i * 22.5}deg`,
804
+ animation: `save-burst-ray 0.85s ${i * 0.03}s cubic-bezier(0,0.6,0.5,1) forwards`,
805
+ transform: `rotate(${i * 22.5}deg)`,
806
+ transformOrigin: '50% 50%',
807
+ opacity: 0,
808
+ }}
809
+ />
810
+ ))}
811
+ </div>
812
+ );
813
+ }
814
+
815
+ interface ElapsedTimerProps {
816
+ running: boolean;
817
+ }
818
+
819
+ export function ElapsedTimer({ running }: ElapsedTimerProps) {
820
+ const [elapsed, setElapsed] = useState(0);
821
+ const startRef = useRef<number | null>(null);
822
+ const rafRef = useRef<number | null>(null);
823
+
824
+ useEffect(() => {
825
+ if (!running) return;
826
+ if (!startRef.current) startRef.current = performance.now();
827
+
828
+ const tick = (): void => {
829
+ setElapsed(Math.floor((performance.now() - (startRef.current ?? 0)) / 1000));
830
+ rafRef.current = requestAnimationFrame(tick);
831
+ };
832
+ rafRef.current = requestAnimationFrame(tick);
833
+ return () => {
834
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
835
+ };
836
+ }, [running]);
837
+
838
+ const s = String(elapsed % 60).padStart(2, '0');
839
+ const m = String(Math.floor(elapsed / 60)).padStart(2, '0');
840
+
841
+ return (
842
+ <span className="font-mono text-xs text-white/25 tracking-widest" aria-live="off">
843
+ {m}:{s}
844
+ </span>
845
+ );
846
+ }
847
+
848
+ END_OF_FILE_CONTENT
849
+ echo "Creating src/components/SaveDrawer/index.ts..."
850
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/index.ts"
851
+ export { SaveDrawer } from './SaveDrawer';
852
+
853
+ END_OF_FILE_CONTENT
854
+ echo "Creating src/components/SaveDrawer/steps.ts..."
855
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/steps.ts"
856
+ import type { DeployStep } from './types';
857
+
858
+ export const DEPLOY_STEPS: readonly DeployStep[] = [
859
+ {
860
+ id: 'commit',
861
+ label: 'Commit',
862
+ verb: 'Committing',
863
+ poem: ['Crystallizing your edit', 'into permanent history.'],
864
+ color: '#60a5fa',
865
+ glyph: '◈',
866
+ duration: 2200,
867
+ },
868
+ {
869
+ id: 'push',
870
+ label: 'Push',
871
+ verb: 'Pushing',
872
+ poem: ['Sending your vision', 'across the wire.'],
873
+ color: '#a78bfa',
874
+ glyph: '◎',
875
+ duration: 2800,
876
+ },
877
+ {
878
+ id: 'build',
879
+ label: 'Build',
880
+ verb: 'Building',
881
+ poem: ['Assembling the pieces,', 'brick by digital brick.'],
882
+ color: '#f59e0b',
883
+ glyph: '⬡',
884
+ duration: 7500,
885
+ },
886
+ {
887
+ id: 'live',
888
+ label: 'Live',
889
+ verb: 'Going live',
890
+ poem: ['Your content', 'is now breathing.'],
891
+ color: '#34d399',
892
+ glyph: '✦',
893
+ duration: 1600,
894
+ },
895
+ ] as const;
896
+
897
+ END_OF_FILE_CONTENT
898
+ echo "Creating src/components/SaveDrawer/types.ts..."
899
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/types.ts"
900
+ /** Save/deploy flow types (aligned with prototype) */
901
+
902
+ export type StepId = 'commit' | 'push' | 'build' | 'live';
903
+
904
+ export type StepState = 'pending' | 'active' | 'done';
905
+
906
+ export type DeployPhase = 'running' | 'done' | 'error';
907
+
908
+ export interface DeployStep {
909
+ id: StepId;
910
+ label: string;
911
+ verb: string;
912
+ poem: [string, string];
913
+ color: string;
914
+ glyph: string;
915
+ duration: number;
916
+ }
917
+
918
+ END_OF_FILE_CONTENT
919
+ echo "Creating src/components/SaveDrawer/useSaveStream.ts..."
920
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/useSaveStream.ts"
921
+ /**
922
+ * useSaveStream — Same API as prototype useSaveOrchestrator, driven by SSE from save-stream.
923
+ * Event handling aligned with CreateTenantFlow (parseSSE, step/log/error/done, post-loop, try/catch).
924
+ * Payload is read from a ref at effect start so the effect does not depend on state.page (avoids re-run and stream kill).
925
+ */
926
+ import { useState, useCallback, useEffect, useRef } from 'react';
927
+ import type { ProjectState } from '@jsonpages/core';
928
+ import type { DeployPhase, StepId, StepState } from './types';
929
+ import { DEPLOY_STEPS } from './steps';
313
930
 
314
931
  function parseSSE(buffer: string): { event: string; data: string }[] {
315
932
  const lines = buffer.split('\n');
@@ -329,7 +946,7 @@ function parseSSE(buffer: string): { event: string; data: string }[] {
329
946
  return out;
330
947
  }
331
948
 
332
- export interface SaveDrawerProps {
949
+ interface UseSaveStreamOptions {
333
950
  cloudUrl: string;
334
951
  apiKey: string;
335
952
  state: ProjectState;
@@ -337,91 +954,152 @@ export interface SaveDrawerProps {
337
954
  onClose: () => void;
338
955
  }
339
956
 
340
- export function SaveDrawer({ cloudUrl, apiKey, state, slug, onClose }: SaveDrawerProps) {
341
- const [completedSteps, setCompletedSteps] = useState<string[]>([]);
342
- const [activeStepId, setActiveStepId] = useState<string | null>('commit');
957
+ export function useSaveStream({
958
+ cloudUrl,
959
+ apiKey,
960
+ state,
961
+ slug,
962
+ onClose,
963
+ }: UseSaveStreamOptions) {
964
+ const [phase, setPhase] = useState<DeployPhase>('running');
965
+ const [doneSteps, setDoneSteps] = useState<StepId[]>([]);
966
+ const [activeStepId, setActiveStepId] = useState<StepId | null>('commit');
967
+ const [progress, setProgress] = useState(0);
968
+ const [deployUrl, setDeployUrl] = useState<string | null>(null);
969
+ const [countdown, setCountdown] = useState(3);
343
970
  const [logs, setLogs] = useState<Record<string, string[]>>({});
344
- const [phase, setPhase] = useState<'running' | 'done' | 'error'>('running');
345
971
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
972
+ const progressRef = useRef(0);
973
+ const [runId, setRunId] = useState(0);
974
+
975
+ const payloadRef = useRef<{ path: string; content: unknown; message: string } | null>(null);
346
976
 
347
977
  const addLog = useCallback((stepId: string, message: string) => {
348
978
  setLogs((prev) => ({ ...prev, [stepId]: [...(prev[stepId] || []), message] }));
349
979
  }, []);
350
980
 
981
+ const stepState = useCallback(
982
+ (i: number): StepState => {
983
+ const step = DEPLOY_STEPS[i];
984
+ if (!step) return 'pending';
985
+ if (doneSteps.includes(step.id)) return 'done';
986
+ if (activeStepId === step.id && phase === 'running') return 'active';
987
+ return 'pending';
988
+ },
989
+ [doneSteps, activeStepId, phase]
990
+ );
991
+
992
+ const currentStep = DEPLOY_STEPS.find((s) => s.id === activeStepId) ?? DEPLOY_STEPS[0] ?? null;
993
+
351
994
  useEffect(() => {
352
995
  let cancelled = false;
353
996
  let gotDone = false;
997
+ setPhase('running');
998
+ setDoneSteps([]);
999
+ setActiveStepId('commit');
1000
+ setProgress(0);
1001
+ setDeployUrl(null);
1002
+ setCountdown(3);
1003
+ setLogs({});
1004
+ setErrorMessage(null);
1005
+ progressRef.current = 0;
1006
+
1007
+ payloadRef.current = {
1008
+ path: `src/data/pages/${slug}.json`,
1009
+ content: state.page,
1010
+ message: `Content update for ${slug} via Visual Editor`,
1011
+ };
1012
+
1013
+ const url = `${cloudUrl.replace(/\/$/, '')}/save-stream`;
354
1014
 
355
1015
  const run = async () => {
356
- const url = `${cloudUrl.replace(/\/$/, '')}/save-stream`;
357
- const res = await fetch(url, {
358
- method: 'POST',
359
- headers: {
360
- 'Content-Type': 'application/json',
361
- Authorization: `Bearer ${apiKey}`,
362
- },
363
- body: JSON.stringify({
364
- path: `src/data/pages/${slug}.json`,
365
- content: state.page,
366
- message: `Content update for ${slug} via Visual Editor`,
367
- }),
368
- });
1016
+ try {
1017
+ const payload = payloadRef.current;
1018
+ if (!payload || cancelled) return;
369
1019
 
370
- 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');
374
- return;
375
- }
1020
+ const res = await fetch(url, {
1021
+ method: 'POST',
1022
+ headers: {
1023
+ 'Content-Type': 'application/json',
1024
+ Authorization: `Bearer ${apiKey}`,
1025
+ },
1026
+ body: JSON.stringify(payload),
1027
+ });
1028
+
1029
+ if (!res.ok || !res.body) {
1030
+ if (!cancelled) {
1031
+ setPhase('error');
1032
+ setErrorMessage('Request failed');
1033
+ }
1034
+ return;
1035
+ }
376
1036
 
377
- const reader = res.body.getReader();
378
- const dec = new TextDecoder();
379
- let buffer = '';
1037
+ const reader = res.body.getReader();
1038
+ const dec = new TextDecoder();
1039
+ let buffer = '';
380
1040
 
381
- while (true) {
382
- const { value, done: streamDone } = await reader.read();
383
- if (streamDone || cancelled) break;
384
- buffer += dec.decode(value, { stream: true });
385
- const parts = buffer.split('\n\n');
386
- buffer = parts.pop() || '';
1041
+ while (true) {
1042
+ const { value, done: streamDone } = await reader.read();
1043
+ if (streamDone || cancelled) break;
1044
+ buffer += dec.decode(value, { stream: true });
1045
+ const parts = buffer.split('\n\n');
1046
+ buffer = parts.pop() || '';
387
1047
 
388
- for (const part of parts) {
389
- const parsed = parseSSE(part + '\n\n');
390
- for (const { event: ev, data: raw } of parsed) {
391
- if (cancelled) return;
392
- try {
393
- const data = JSON.parse(raw) as Record<string, unknown>;
394
- if (ev === 'step') {
395
- const id = data.id as string | undefined;
396
- const status = data.status as string | undefined;
397
- if (id && status === 'running') setActiveStepId(id);
398
- if (id && status === 'done') {
399
- setCompletedSteps((prev) => [...prev, id].filter(Boolean));
1048
+ for (const part of parts) {
1049
+ const parsed = parseSSE(part + '\n\n');
1050
+ for (const { event: ev, data: raw } of parsed) {
1051
+ if (cancelled) return;
1052
+ try {
1053
+ const data = JSON.parse(raw) as Record<string, unknown>;
1054
+ if (ev === 'step') {
1055
+ const id = data.id as StepId | undefined;
1056
+ const status = data.status as string | undefined;
1057
+ if (id && status === 'running') {
1058
+ setActiveStepId(id);
1059
+ const idx = DEPLOY_STEPS.findIndex((s) => s.id === id);
1060
+ if (idx >= 0) progressRef.current = Math.round((idx / DEPLOY_STEPS.length) * 100);
1061
+ setProgress(progressRef.current);
1062
+ }
1063
+ if (id && status === 'done') {
1064
+ setDoneSteps((prev) => [...prev, id].filter(Boolean));
1065
+ setActiveStepId(null);
1066
+ const idx = DEPLOY_STEPS.findIndex((s) => s.id === id);
1067
+ progressRef.current = idx >= 0 ? Math.round(((idx + 1) / DEPLOY_STEPS.length) * 100) : 100;
1068
+ setProgress(progressRef.current);
1069
+ }
1070
+ } else if (ev === 'log' && data.stepId && data.message) {
1071
+ addLog(data.stepId as string, data.message as string);
1072
+ } else if (ev === 'error') {
1073
+ if (!cancelled) {
1074
+ setErrorMessage((data.message as string) || 'Errore');
1075
+ setPhase('error');
1076
+ }
1077
+ return;
1078
+ } else if (ev === 'done') {
1079
+ gotDone = true;
1080
+ setDeployUrl((data.deployUrl as string) ?? null);
1081
+ setProgress(100);
1082
+ setDoneSteps(['commit', 'push', 'build', 'live']);
400
1083
  setActiveStepId(null);
1084
+ setPhase('done');
1085
+ return;
401
1086
  }
402
- } else if (ev === 'log' && data.stepId && data.message) {
403
- addLog(data.stepId as string, data.message as string);
404
- } else if (ev === 'error') {
405
- setErrorMessage((data.message as string) || 'Error');
406
- setPhase('error');
407
- return;
408
- } else if (ev === 'done') {
409
- gotDone = true;
410
- setCompletedSteps((prev) => [...new Set([...prev, 'commit', 'push', 'build', 'live'])]);
411
- setActiveStepId(null);
412
- setPhase('done');
413
- return;
1087
+ } catch {
1088
+ // ignore parse errors
414
1089
  }
415
- } catch {
416
- // ignore parse errors
417
1090
  }
418
1091
  }
419
1092
  }
420
- }
421
1093
 
422
- if (!cancelled && !gotDone) {
423
- setErrorMessage('Connection closed before completion');
424
- setPhase('error');
1094
+ if (!cancelled && !gotDone) {
1095
+ setErrorMessage('Operazione interrotta o build non completato.');
1096
+ setPhase('error');
1097
+ }
1098
+ } catch (e) {
1099
+ if (!cancelled) {
1100
+ setErrorMessage(e instanceof Error ? e.message : 'Errore');
1101
+ setPhase('error');
1102
+ }
425
1103
  }
426
1104
  };
427
1105
 
@@ -429,142 +1107,40 @@ export function SaveDrawer({ cloudUrl, apiKey, state, slug, onClose }: SaveDrawe
429
1107
  return () => {
430
1108
  cancelled = true;
431
1109
  };
432
- }, [cloudUrl, apiKey, state.page, slug, addLog]);
433
-
434
- const progressPct =
435
- phase === 'done' ? 100 : SAVE_STEPS.length > 0 ? Math.round((completedSteps.length / SAVE_STEPS.length) * 100) : 0;
436
-
437
- const stepState = (id: string) => {
438
- if (completedSteps.includes(id)) return 'done';
439
- if (activeStepId === id) return 'active';
440
- return 'pending';
441
- };
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>
1110
+ }, [cloudUrl, apiKey, slug, runId, addLog]);
469
1111
 
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>
1112
+ useEffect(() => {
1113
+ if (phase !== 'done') return;
1114
+ setCountdown(3);
1115
+ const iv = setInterval(() => {
1116
+ setCountdown((s) => {
1117
+ if (s <= 1) {
1118
+ clearInterval(iv);
1119
+ onClose();
1120
+ return 0;
1121
+ }
1122
+ return s - 1;
1123
+ });
1124
+ }, 1000);
1125
+ return () => clearInterval(iv);
1126
+ }, [phase, onClose]);
518
1127
 
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
- )}
1128
+ const retry = useCallback(() => setRunId((r) => r + 1), []);
526
1129
 
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
- );
1130
+ return {
1131
+ phase,
1132
+ currentStep,
1133
+ stepState,
1134
+ doneSteps,
1135
+ progress,
1136
+ deployUrl,
1137
+ countdown,
1138
+ logs,
1139
+ errorMessage,
1140
+ retry,
1141
+ };
561
1142
  }
562
1143
 
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
1144
  END_OF_FILE_CONTENT
569
1145
  mkdir -p "src/components/arch-layers"
570
1146
  echo "Creating src/components/arch-layers/View.tsx..."
@@ -3403,6 +3979,46 @@ export { Label }
3403
3979
 
3404
3980
 
3405
3981
 
3982
+ END_OF_FILE_CONTENT
3983
+ echo "Creating src/components/ui/progress.tsx..."
3984
+ cat << 'END_OF_FILE_CONTENT' > "src/components/ui/progress.tsx"
3985
+ import * as React from 'react';
3986
+ import { cn } from '@/lib/utils';
3987
+
3988
+ interface ProgressProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
3989
+ value?: number | null;
3990
+ indicatorClassName?: string;
3991
+ indicatorStyle?: React.CSSProperties;
3992
+ }
3993
+
3994
+ const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
3995
+ ({ className, value, indicatorClassName, indicatorStyle, ...props }, ref) => (
3996
+ <div
3997
+ ref={ref}
3998
+ role="progressbar"
3999
+ aria-valuenow={value ?? 0}
4000
+ aria-valuemin={0}
4001
+ aria-valuemax={100}
4002
+ className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}
4003
+ {...props}
4004
+ >
4005
+ <div
4006
+ className={cn(
4007
+ 'h-full w-full flex-1 bg-primary transition-all duration-700 ease-out',
4008
+ indicatorClassName
4009
+ )}
4010
+ style={{
4011
+ transform: `translateX(-${100 - (value ?? 0)}%)`,
4012
+ ...indicatorStyle,
4013
+ }}
4014
+ />
4015
+ </div>
4016
+ )
4017
+ );
4018
+ Progress.displayName = 'Progress';
4019
+
4020
+ export { Progress };
4021
+
3406
4022
  END_OF_FILE_CONTENT
3407
4023
  echo "Creating src/components/ui/select.tsx..."
3408
4024
  cat << 'END_OF_FILE_CONTENT' > "src/components/ui/select.tsx"
@@ -4798,6 +5414,88 @@ html {
4798
5414
  opacity: 0;
4799
5415
  }
4800
5416
 
5417
+ /* ==========================================================================
5418
+ SAVE DRAWER (prototype-identical animations)
5419
+ ========================================================================== */
5420
+ @keyframes save-drawer-up {
5421
+ from { transform: translateY(100%); opacity: 0; }
5422
+ to { transform: translateY(0); opacity: 1; }
5423
+ }
5424
+ @keyframes save-fade-in {
5425
+ from { opacity: 0; }
5426
+ to { opacity: 1; }
5427
+ }
5428
+ @keyframes save-fade-up {
5429
+ from { opacity: 0; transform: translateY(8px); }
5430
+ to { opacity: 1; transform: translateY(0); }
5431
+ }
5432
+ @keyframes save-success-pop {
5433
+ 0% { transform: scale(0.88); opacity: 0; }
5434
+ 60% { transform: scale(1.04); }
5435
+ 100% { transform: scale(1); opacity: 1; }
5436
+ }
5437
+ @keyframes save-ring-expand {
5438
+ from { transform: scale(1); opacity: 0.7; }
5439
+ to { transform: scale(2.1); opacity: 0; }
5440
+ }
5441
+ @keyframes save-orb-travel {
5442
+ from { left: 0%; }
5443
+ to { left: calc(100% - 10px); }
5444
+ }
5445
+ @keyframes save-glyph-rotate {
5446
+ from { transform: rotate(0deg); }
5447
+ to { transform: rotate(360deg); }
5448
+ }
5449
+ @keyframes save-particle-float {
5450
+ 0% { transform: translateY(0) scale(1); opacity: 0; }
5451
+ 15% { opacity: 1; }
5452
+ 100% { transform: translateY(-90px) scale(0.3); opacity: 0; }
5453
+ }
5454
+ @keyframes save-bar-eq {
5455
+ from { transform: scaleY(0.4); }
5456
+ to { transform: scaleY(1); }
5457
+ }
5458
+ @keyframes save-ambient-pulse {
5459
+ 0%, 100% { opacity: 0.3; }
5460
+ 50% { opacity: 0.65; }
5461
+ }
5462
+ @keyframes save-shimmer-sweep {
5463
+ from { transform: translateX(-100%); }
5464
+ to { transform: translateX(250%); }
5465
+ }
5466
+ @keyframes save-text-in {
5467
+ from { opacity: 0; transform: translateX(-6px); }
5468
+ to { opacity: 1; transform: translateX(0); }
5469
+ }
5470
+ @keyframes save-node-glow {
5471
+ 0%, 100% { box-shadow: 0 0 12px var(--glow-color, #60a5fa55); }
5472
+ 50% { box-shadow: 0 0 28px var(--glow-color, #60a5fa88), 0 0 48px var(--glow-color, #60a5fa22); }
5473
+ }
5474
+ @keyframes save-burst-ray {
5475
+ 0% { transform: rotate(var(--r, 0deg)) translateX(0); opacity: 1; }
5476
+ 100% { transform: rotate(var(--r, 0deg)) translateX(56px); opacity: 0; }
5477
+ }
5478
+
5479
+ .save-drawer-up { animation: save-drawer-up 0.45s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
5480
+ .save-fade-in { animation: save-fade-in 0.25s ease forwards; }
5481
+ .save-fade-up { animation: save-fade-up 0.35s ease forwards; }
5482
+ .save-success-pop { animation: save-success-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; }
5483
+ .save-text-in { animation: save-text-in 0.3s ease forwards; }
5484
+
5485
+ .stroke-dash-30 { stroke-dasharray: 30; stroke-dashoffset: 30; }
5486
+ .save-check-draw { animation: save-check-draw 0.4s 0.05s ease forwards; }
5487
+ @keyframes save-check-draw {
5488
+ to { stroke-dashoffset: 0; }
5489
+ }
5490
+
5491
+ /* Countdown drain — 3s linear (dopa-save identical) */
5492
+ .countdown-bar {
5493
+ animation: countdown-drain 3s linear forwards;
5494
+ }
5495
+ @keyframes countdown-drain {
5496
+ from { width: 100%; }
5497
+ to { width: 0%; }
5498
+ }
4801
5499
 
4802
5500
 
4803
5501
  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.48",
4
4
  "description": "The Sovereign CLI Engine for JsonPages.",
5
5
  "type": "module",
6
6
  "bin": {