@jsonpages/cli 3.0.45 → 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.
@@ -41,6 +41,7 @@ import { addSectionConfig } from '@/lib/addSectionConfig';
41
41
  import { getHydratedData } from '@/lib/draftStorage';
42
42
  import type { JsonPagesConfig, ProjectState } from '@jsonpages/core';
43
43
  import type { SiteConfig, ThemeConfig, MenuConfig } from '@/types';
44
+ import { SaveDrawer } from '@/components/SaveDrawer';
44
45
 
45
46
  import siteData from '@/data/config/site.json';
46
47
  import themeData from '@/data/config/theme.json';
@@ -69,6 +70,7 @@ function getInitialData() {
69
70
  function App() {
70
71
  const [{ pages, siteConfig }] = useState(getInitialData);
71
72
  const [assetsManifest, setAssetsManifest] = useState<LibraryImageEntry[]>([]);
73
+ const [saveDrawer, setSaveDrawer] = useState<{ state: ProjectState; slug: string } | null>(null);
72
74
 
73
75
  useEffect(() => {
74
76
  // In Cloud mode, listing assets might be different or disabled for MVP
@@ -78,11 +80,7 @@ function App() {
78
80
  .then((list: LibraryImageEntry[]) => setAssetsManifest(Array.isArray(list) ? list : []))
79
81
  .catch(() => setAssetsManifest([]));
80
82
  }, []);
81
-
82
- console.log("🔍 DEBUG ENV:", {
83
- URL: import.meta.env.VITE_JSONPAGES_CLOUD_URL,
84
- KEY: import.meta.env.VITE_JSONPAGES_API_KEY ? "PRESENT" : "MISSING"
85
- });
83
+
86
84
  const config: JsonPagesConfig = {
87
85
  tenantId: TENANT_ID,
88
86
  registry: ComponentRegistry as JsonPagesConfig['registry'],
@@ -96,29 +94,9 @@ console.log("🔍 DEBUG ENV:", {
96
94
  persistence: {
97
95
  async saveToFile(state: ProjectState, slug: string): Promise<void> {
98
96
 
99
- // ☁️ SCENARIO A: CLOUD BRIDGE (Production)
97
+ // ☁️ SCENARIO A: CLOUD BRIDGE (Production) — open SaveDrawer, stream save-stream
100
98
  if (CLOUD_API_URL && CLOUD_API_KEY) {
101
- console.log(`☁️ Saving ${slug} via Cloud Bridge...`);
102
-
103
- const res = await fetch(`${CLOUD_API_URL}/save`, {
104
- method: 'POST',
105
- headers: {
106
- 'Content-Type': 'application/json',
107
- 'Authorization': `Bearer ${CLOUD_API_KEY}`
108
- },
109
- body: JSON.stringify({
110
- // Mapping logical slug to physical path in repo
111
- path: `src/data/pages/${slug}.json`,
112
- // We save the page config specifically
113
- content: state.page,
114
- message: `Content update for ${slug} via Visual Editor`
115
- }),
116
- });
117
-
118
- if (!res.ok) {
119
- const err = await res.json().catch(() => ({}));
120
- throw new Error(err.error || `Cloud save failed: ${res.status}`);
121
- }
99
+ setSaveDrawer({ state, slug });
122
100
  return;
123
101
  }
124
102
 
@@ -164,7 +142,20 @@ console.log("🔍 DEBUG ENV:", {
164
142
  },
165
143
  };
166
144
 
167
- return <JsonPagesEngine config={config} />;
145
+ return (
146
+ <>
147
+ <JsonPagesEngine config={config} />
148
+ {saveDrawer && CLOUD_API_URL && CLOUD_API_KEY && (
149
+ <SaveDrawer
150
+ cloudUrl={CLOUD_API_URL}
151
+ apiKey={CLOUD_API_KEY}
152
+ state={saveDrawer.state}
153
+ slug={saveDrawer.slug}
154
+ onClose={() => setSaveDrawer(null)}
155
+ />
156
+ )}
157
+ </>
158
+ );
168
159
  }
169
160
 
170
161
  export default App;
@@ -301,6 +292,747 @@ export const NotFound: React.FC = () => {
301
292
 
302
293
 
303
294
 
295
+ END_OF_FILE_CONTENT
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
439
+ echo "Creating src/components/SaveDrawer/SaveDrawer.tsx..."
440
+ cat << 'END_OF_FILE_CONTENT' > "src/components/SaveDrawer/SaveDrawer.tsx"
441
+ /**
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".
445
+ */
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';
454
+ import type { ProjectState } from '@jsonpages/core';
455
+
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';
884
+
885
+ function parseSSE(buffer: string): { event: string; data: string }[] {
886
+ const lines = buffer.split('\n');
887
+ const out: { event: string; data: string }[] = [];
888
+ let event = 'message';
889
+ let data = '';
890
+ for (const line of lines) {
891
+ if (line.startsWith('event:')) event = line.slice(6).trim();
892
+ else if (line.startsWith('data:')) data = line.slice(5).trim();
893
+ else if (line === '' && data) {
894
+ out.push({ event, data });
895
+ data = '';
896
+ event = 'message';
897
+ }
898
+ }
899
+ if (data) out.push({ event, data });
900
+ return out;
901
+ }
902
+
903
+ interface UseSaveStreamOptions {
904
+ cloudUrl: string;
905
+ apiKey: string;
906
+ state: ProjectState;
907
+ slug: string;
908
+ onClose: () => void;
909
+ }
910
+
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
+ );
935
+
936
+ const currentStep = DEPLOY_STEPS.find((s) => s.id === activeStepId) ?? DEPLOY_STEPS[0] ?? null;
937
+
938
+ useEffect(() => {
939
+ let cancelled = false;
940
+ setPhase('running');
941
+ setDoneSteps([]);
942
+ setActiveStepId('commit');
943
+ setProgress(0);
944
+ progressRef.current = 0;
945
+
946
+ const url = `${cloudUrl.replace(/\/$/, '')}/save-stream`;
947
+
948
+ const run = async () => {
949
+ const res = await fetch(url, {
950
+ method: 'POST',
951
+ headers: {
952
+ 'Content-Type': 'application/json',
953
+ Authorization: `Bearer ${apiKey}`,
954
+ },
955
+ body: JSON.stringify({
956
+ path: `src/data/pages/${slug}.json`,
957
+ content: state.page,
958
+ message: `Content update for ${slug} via Visual Editor`,
959
+ }),
960
+ });
961
+
962
+ if (!res.ok || !res.body) {
963
+ if (!cancelled) setPhase('error');
964
+ return;
965
+ }
966
+
967
+ const reader = res.body.getReader();
968
+ const dec = new TextDecoder();
969
+ let buffer = '';
970
+
971
+ while (true) {
972
+ const { value, done: streamDone } = await reader.read();
973
+ if (streamDone || cancelled) break;
974
+ buffer += dec.decode(value, { stream: true });
975
+ const parts = buffer.split('\n\n');
976
+ buffer = parts.pop() || '';
977
+
978
+ for (const part of parts) {
979
+ const parsed = parseSSE(part + '\n\n');
980
+ for (const { event: ev, data: raw } of parsed) {
981
+ if (cancelled) return;
982
+ try {
983
+ const data = JSON.parse(raw) as Record<string, unknown>;
984
+ if (ev === 'step') {
985
+ const id = data.id as StepId | undefined;
986
+ const status = data.status as string | undefined;
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
+ }
993
+ if (id && status === 'done') {
994
+ setDoneSteps((prev) => [...prev, id].filter(Boolean));
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);
999
+ }
1000
+ } else if (ev === 'error') {
1001
+ setPhase('error');
1002
+ return;
1003
+ } else if (ev === 'done') {
1004
+ setProgress(100);
1005
+ setDoneSteps(['commit', 'push', 'build', 'live']);
1006
+ setActiveStepId(null);
1007
+ setPhase('done');
1008
+ return;
1009
+ }
1010
+ } catch {
1011
+ // ignore
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+ };
1017
+
1018
+ run();
1019
+ return () => {
1020
+ cancelled = true;
1021
+ };
1022
+ }, [cloudUrl, apiKey, slug, state.page, runId]);
1023
+
1024
+ const retry = useCallback(() => setRunId((r) => r + 1), []);
1025
+
1026
+ return {
1027
+ phase,
1028
+ currentStep,
1029
+ stepState,
1030
+ doneSteps,
1031
+ progress,
1032
+ retry,
1033
+ };
1034
+ }
1035
+
304
1036
  END_OF_FILE_CONTENT
305
1037
  mkdir -p "src/components/arch-layers"
306
1038
  echo "Creating src/components/arch-layers/View.tsx..."
@@ -4534,6 +5266,79 @@ html {
4534
5266
  opacity: 0;
4535
5267
  }
4536
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
+ }
4537
5342
 
4538
5343
 
4539
5344
  END_OF_FILE_CONTENT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonpages/cli",
3
- "version": "3.0.45",
3
+ "version": "3.0.47",
4
4
  "description": "The Sovereign CLI Engine for JsonPages.",
5
5
  "type": "module",
6
6
  "bin": {