@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.
- package/assets/src_tenant_alpha.sh +833 -28
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|