@clypra/ui 1.0.0
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/.releaserc.json +16 -0
- package/package.json +47 -0
- package/src/components/GraphInspector/GraphInspector.tsx +91 -0
- package/src/components/GraphInspector/index.ts +2 -0
- package/src/components/PassInspector/PassInspector.tsx +128 -0
- package/src/components/PassInspector/index.ts +2 -0
- package/src/components/PerformanceMonitor/PerformanceMonitor.tsx +129 -0
- package/src/components/PerformanceMonitor/index.ts +2 -0
- package/src/components/PresetManager/PresetManager.tsx +420 -0
- package/src/components/PresetManager/index.ts +2 -0
- package/src/components/PreviewCanvas/PreviewCanvas.tsx +168 -0
- package/src/components/PreviewCanvas/index.ts +2 -0
- package/src/components/ResourceInspector/ResourceInspector.tsx +126 -0
- package/src/components/ResourceInspector/index.ts +2 -0
- package/src/components/Timeline/Timeline.tsx +291 -0
- package/src/components/Timeline/index.ts +2 -0
- package/src/components/ValidationPanel/ValidationPanel.tsx +428 -0
- package/src/components/ValidationPanel/index.ts +2 -0
- package/src/index.ts +35 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +14 -0
package/.releaserc.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"branches": ["main"],
|
|
3
|
+
"plugins": [
|
|
4
|
+
"@semantic-release/commit-analyzer",
|
|
5
|
+
"@semantic-release/release-notes-generator",
|
|
6
|
+
"@semantic-release/npm",
|
|
7
|
+
[
|
|
8
|
+
"@semantic-release/git",
|
|
9
|
+
{
|
|
10
|
+
"assets": ["package.json"],
|
|
11
|
+
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"@semantic-release/github"
|
|
15
|
+
]
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clypra/ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared UI components for all Clypra Studio Labs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./dist/index.js",
|
|
10
|
+
"./components/*": "./dist/components/*/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup",
|
|
14
|
+
"dev": "tsup --watch",
|
|
15
|
+
"lint": "tsc --noEmit",
|
|
16
|
+
"clean": "rm -rf dist"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@clypra/runtime": "^1.0.0",
|
|
20
|
+
"react": "^18.3.1",
|
|
21
|
+
"react-dom": "^18.3.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/react": "^18.3.12",
|
|
25
|
+
"@types/react-dom": "^18.3.1",
|
|
26
|
+
"@types/node": "^22.14.0",
|
|
27
|
+
"typescript": "~5.8.2",
|
|
28
|
+
"tsup": "^8.3.5"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"react": "^18.0.0",
|
|
32
|
+
"react-dom": "^18.0.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/AIEraDev/clypra-studio.git",
|
|
37
|
+
"directory": "packages/ui"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/AIEraDev/clypra-studio/tree/main/packages/ui#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/AIEraDev/clypra-studio/issues"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"registry": "https://registry.npmjs.org/"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphInspector Component
|
|
3
|
+
*
|
|
4
|
+
* Visualizes the media processing graph structure.
|
|
5
|
+
* Shows nodes, edges, capabilities, and dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import type { MediaProcessingGraph, GraphNode } from "@clypra/runtime/graph";
|
|
10
|
+
|
|
11
|
+
export interface GraphInspectorProps {
|
|
12
|
+
graph: MediaProcessingGraph;
|
|
13
|
+
selectedNodeId?: string;
|
|
14
|
+
onNodeSelect?: (nodeId: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const GraphInspector: React.FC<GraphInspectorProps> = ({ graph, selectedNodeId, onNodeSelect }) => {
|
|
18
|
+
return (
|
|
19
|
+
<div className="graph-inspector" style={{ padding: "16px", fontFamily: "monospace" }}>
|
|
20
|
+
<div style={{ marginBottom: "16px", fontWeight: "bold", fontSize: "14px" }}>Graph Inspector</div>
|
|
21
|
+
|
|
22
|
+
<div style={{ marginBottom: "12px" }}>
|
|
23
|
+
<div style={{ fontSize: "12px", color: "#666" }}>Graph ID: {graph.id}</div>
|
|
24
|
+
<div style={{ fontSize: "12px", color: "#666" }}>
|
|
25
|
+
Nodes: {graph.nodes.length} | Edges: {graph.edges.length}
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div style={{ marginBottom: "16px" }}>
|
|
30
|
+
<div style={{ fontSize: "13px", fontWeight: "600", marginBottom: "8px" }}>Nodes</div>
|
|
31
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
|
32
|
+
{graph.nodes.map((node) => (
|
|
33
|
+
<NodeItem key={node.id} node={node} selected={node.id === selectedNodeId} onClick={() => onNodeSelect?.(node.id)} />
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div>
|
|
39
|
+
<div style={{ fontSize: "13px", fontWeight: "600", marginBottom: "8px" }}>Edges</div>
|
|
40
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
|
41
|
+
{graph.edges.map((edge, idx) => (
|
|
42
|
+
<div
|
|
43
|
+
key={idx}
|
|
44
|
+
style={{
|
|
45
|
+
fontSize: "11px",
|
|
46
|
+
color: "#555",
|
|
47
|
+
padding: "4px 8px",
|
|
48
|
+
backgroundColor: "#f8f8f8",
|
|
49
|
+
borderRadius: "4px",
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{edge.fromNodeId}.{edge.fromPinId} → {edge.toNodeId}.{edge.toPinId}
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
interface NodeItemProps {
|
|
62
|
+
node: GraphNode;
|
|
63
|
+
selected: boolean;
|
|
64
|
+
onClick: () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const NodeItem: React.FC<NodeItemProps> = ({ node, selected, onClick }) => {
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
onClick={onClick}
|
|
71
|
+
style={{
|
|
72
|
+
padding: "8px",
|
|
73
|
+
backgroundColor: selected ? "#e3f2fd" : "#fafafa",
|
|
74
|
+
border: selected ? "1px solid #2196f3" : "1px solid #e0e0e0",
|
|
75
|
+
borderRadius: "4px",
|
|
76
|
+
cursor: "pointer",
|
|
77
|
+
fontSize: "12px",
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<div style={{ fontWeight: "600", marginBottom: "4px" }}>
|
|
81
|
+
{node.id} <span style={{ color: "#666", fontWeight: "normal" }}>({node.type})</span>
|
|
82
|
+
</div>
|
|
83
|
+
<div style={{ fontSize: "11px", color: "#777" }}>
|
|
84
|
+
Inputs: {Object.keys(node.inputs).length} | Outputs: {Object.keys(node.outputs).length}
|
|
85
|
+
</div>
|
|
86
|
+
{node.capabilities.temporal && <div style={{ fontSize: "10px", color: "#f57c00", marginTop: "4px" }}>⏱ Temporal</div>}
|
|
87
|
+
{node.capabilities.stateful && <div style={{ fontSize: "10px", color: "#7b1fa2", marginTop: "2px" }}>💾 Stateful</div>}
|
|
88
|
+
{node.requirements.multipass && <div style={{ fontSize: "10px", color: "#d32f2f", marginTop: "2px" }}>🔄 Multi-pass</div>}
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PassInspector Component
|
|
3
|
+
*
|
|
4
|
+
* Shows render passes from the frame graph.
|
|
5
|
+
* Displays execution order, shaders, uniforms, and textures.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from "react";
|
|
9
|
+
import type { FrameGraph, RenderPass } from "@clypra/runtime/planner";
|
|
10
|
+
|
|
11
|
+
export interface PassInspectorProps {
|
|
12
|
+
frameGraph: FrameGraph;
|
|
13
|
+
selectedPassId?: string;
|
|
14
|
+
onPassSelect?: (passId: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const PassInspector: React.FC<PassInspectorProps> = ({ frameGraph, selectedPassId, onPassSelect }) => {
|
|
18
|
+
return (
|
|
19
|
+
<div className="pass-inspector" style={{ padding: "16px", fontFamily: "monospace" }}>
|
|
20
|
+
<div style={{ marginBottom: "16px", fontWeight: "bold", fontSize: "14px" }}>Pass Inspector</div>
|
|
21
|
+
|
|
22
|
+
<div style={{ marginBottom: "12px" }}>
|
|
23
|
+
<div style={{ fontSize: "12px", color: "#666" }}>
|
|
24
|
+
Frame: {frameGraph.frameNumber} | Time: {frameGraph.timelineTimeMs.toFixed(2)}ms
|
|
25
|
+
</div>
|
|
26
|
+
<div style={{ fontSize: "12px", color: "#666" }}>Total Passes: {frameGraph.passes.length}</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
|
30
|
+
{frameGraph.passes.map((pass, index) => (
|
|
31
|
+
<PassCard key={pass.id} pass={pass} index={index} selected={pass.id === selectedPassId} onClick={() => onPassSelect?.(pass.id)} />
|
|
32
|
+
))}
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
interface PassCardProps {
|
|
39
|
+
pass: RenderPass;
|
|
40
|
+
index: number;
|
|
41
|
+
selected: boolean;
|
|
42
|
+
onClick: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const PassCard: React.FC<PassCardProps> = ({ pass, index, selected, onClick }) => {
|
|
46
|
+
const uniformCount = Object.keys(pass.uniforms).length;
|
|
47
|
+
const inputCount = pass.inputs.length;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
onClick={onClick}
|
|
52
|
+
style={{
|
|
53
|
+
padding: "12px",
|
|
54
|
+
backgroundColor: selected ? "#e8f5e9" : "#fafafa",
|
|
55
|
+
border: selected ? "2px solid #4caf50" : "1px solid #e0e0e0",
|
|
56
|
+
borderRadius: "6px",
|
|
57
|
+
cursor: "pointer",
|
|
58
|
+
fontSize: "12px",
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{/* Header */}
|
|
62
|
+
<div style={{ display: "flex", alignItems: "center", marginBottom: "8px" }}>
|
|
63
|
+
<div
|
|
64
|
+
style={{
|
|
65
|
+
width: "24px",
|
|
66
|
+
height: "24px",
|
|
67
|
+
borderRadius: "50%",
|
|
68
|
+
backgroundColor: "#2196f3",
|
|
69
|
+
color: "white",
|
|
70
|
+
display: "flex",
|
|
71
|
+
alignItems: "center",
|
|
72
|
+
justifyContent: "center",
|
|
73
|
+
fontSize: "11px",
|
|
74
|
+
fontWeight: "bold",
|
|
75
|
+
marginRight: "8px",
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{index + 1}
|
|
79
|
+
</div>
|
|
80
|
+
<div style={{ flex: 1 }}>
|
|
81
|
+
<div style={{ fontWeight: "600" }}>{pass.name || pass.id}</div>
|
|
82
|
+
<div style={{ fontSize: "10px", color: "#666" }}>Shader: {pass.shaderId}</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
{/* Resources */}
|
|
87
|
+
<div style={{ marginBottom: "8px", paddingLeft: "32px" }}>
|
|
88
|
+
<div style={{ fontSize: "11px", color: "#555" }}>
|
|
89
|
+
<span style={{ fontWeight: "500" }}>Inputs:</span> {inputCount > 0 ? pass.inputs.join(", ") : "none"}
|
|
90
|
+
</div>
|
|
91
|
+
<div style={{ fontSize: "11px", color: "#555" }}>
|
|
92
|
+
<span style={{ fontWeight: "500" }}>Output:</span> {pass.output}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{/* Uniforms */}
|
|
97
|
+
{uniformCount > 0 && (
|
|
98
|
+
<div style={{ paddingLeft: "32px", fontSize: "10px", color: "#777" }}>
|
|
99
|
+
<div style={{ fontWeight: "500", marginBottom: "4px" }}>Uniforms ({uniformCount}):</div>
|
|
100
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
|
|
101
|
+
{Object.entries(pass.uniforms)
|
|
102
|
+
.slice(0, 5)
|
|
103
|
+
.map(([key, value]) => (
|
|
104
|
+
<div
|
|
105
|
+
key={key}
|
|
106
|
+
style={{
|
|
107
|
+
padding: "2px 6px",
|
|
108
|
+
backgroundColor: "#fff",
|
|
109
|
+
border: "1px solid #ddd",
|
|
110
|
+
borderRadius: "3px",
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{key}: {JSON.stringify(value)}
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
{uniformCount > 5 && <div style={{ padding: "2px 6px", color: "#999" }}>+{uniformCount - 5} more</div>}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{/* Flags */}
|
|
122
|
+
<div style={{ paddingLeft: "32px", marginTop: "8px", fontSize: "10px" }}>
|
|
123
|
+
{pass.clearBeforeRender && <span style={{ padding: "2px 6px", backgroundColor: "#fff3e0", color: "#e65100", borderRadius: "3px", marginRight: "4px" }}>Clear</span>}
|
|
124
|
+
{pass.blendMode && <span style={{ padding: "2px 6px", backgroundColor: "#f3e5f5", color: "#6a1b9a", borderRadius: "3px" }}>Blend: {pass.blendMode}</span>}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PerformanceMonitor Component
|
|
3
|
+
*
|
|
4
|
+
* Shows real-time performance metrics: GPU/CPU time, FPS, pass breakdown.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import React from "react";
|
|
8
|
+
|
|
9
|
+
export interface PerformanceMetrics {
|
|
10
|
+
gpuTime: number; // ms
|
|
11
|
+
cpuTime: number; // ms
|
|
12
|
+
passCount: number;
|
|
13
|
+
fps: number;
|
|
14
|
+
memoryUsage: number; // bytes
|
|
15
|
+
passTimes?: Array<{ passId: string; time: number }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PerformanceMonitorProps {
|
|
19
|
+
metrics: PerformanceMetrics;
|
|
20
|
+
targetFps?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const PerformanceMonitor: React.FC<PerformanceMonitorProps> = ({ metrics, targetFps = 60 }) => {
|
|
24
|
+
const targetFrameTime = 1000 / targetFps;
|
|
25
|
+
const totalTime = metrics.gpuTime + metrics.cpuTime;
|
|
26
|
+
const isOverBudget = totalTime > targetFrameTime;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="performance-monitor" style={{ padding: "16px", fontFamily: "monospace" }}>
|
|
30
|
+
<div style={{ marginBottom: "16px", fontWeight: "bold", fontSize: "14px" }}>Performance Monitor</div>
|
|
31
|
+
|
|
32
|
+
{/* FPS Display */}
|
|
33
|
+
<div
|
|
34
|
+
style={{
|
|
35
|
+
marginBottom: "16px",
|
|
36
|
+
padding: "16px",
|
|
37
|
+
backgroundColor: isOverBudget ? "#ffebee" : "#e8f5e9",
|
|
38
|
+
borderRadius: "8px",
|
|
39
|
+
textAlign: "center",
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<div style={{ fontSize: "32px", fontWeight: "bold", color: isOverBudget ? "#c62828" : "#2e7d32" }}>{metrics.fps.toFixed(1)} FPS</div>
|
|
43
|
+
<div style={{ fontSize: "12px", color: "#666", marginTop: "4px" }}>
|
|
44
|
+
Target: {targetFps} FPS ({targetFrameTime.toFixed(2)}ms)
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{/* Timing Breakdown */}
|
|
49
|
+
<div style={{ marginBottom: "16px" }}>
|
|
50
|
+
<div style={{ fontSize: "13px", fontWeight: "600", marginBottom: "8px" }}>Frame Time: {totalTime.toFixed(2)}ms</div>
|
|
51
|
+
|
|
52
|
+
<MetricBar label="GPU" value={metrics.gpuTime} max={targetFrameTime} color="#2196f3" />
|
|
53
|
+
<MetricBar label="CPU" value={metrics.cpuTime} max={targetFrameTime} color="#ff9800" />
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{/* Pass Count */}
|
|
57
|
+
<div style={{ marginBottom: "16px", fontSize: "12px" }}>
|
|
58
|
+
<div style={{ marginBottom: "4px" }}>
|
|
59
|
+
<span style={{ fontWeight: "600" }}>Render Passes:</span> {metrics.passCount}
|
|
60
|
+
</div>
|
|
61
|
+
<div>
|
|
62
|
+
<span style={{ fontWeight: "600" }}>Memory Usage:</span> {formatBytes(metrics.memoryUsage)}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{/* Pass Time Breakdown */}
|
|
67
|
+
{metrics.passTimes && metrics.passTimes.length > 0 && (
|
|
68
|
+
<div>
|
|
69
|
+
<div style={{ fontSize: "13px", fontWeight: "600", marginBottom: "8px" }}>Pass Breakdown</div>
|
|
70
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
|
71
|
+
{metrics.passTimes.map((pass, index) => (
|
|
72
|
+
<div
|
|
73
|
+
key={pass.passId}
|
|
74
|
+
style={{
|
|
75
|
+
display: "flex",
|
|
76
|
+
alignItems: "center",
|
|
77
|
+
fontSize: "11px",
|
|
78
|
+
padding: "4px 8px",
|
|
79
|
+
backgroundColor: "#f5f5f5",
|
|
80
|
+
borderRadius: "4px",
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<div style={{ width: "20px", fontWeight: "600", color: "#666" }}>{index + 1}</div>
|
|
84
|
+
<div style={{ flex: 1, marginRight: "8px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{pass.passId}</div>
|
|
85
|
+
<div style={{ fontWeight: "600", color: "#1976d2" }}>{pass.time.toFixed(2)}ms</div>
|
|
86
|
+
</div>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
interface MetricBarProps {
|
|
96
|
+
label: string;
|
|
97
|
+
value: number;
|
|
98
|
+
max: number;
|
|
99
|
+
color: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const MetricBar: React.FC<MetricBarProps> = ({ label, value, max, color }) => {
|
|
103
|
+
const percentage = Math.min((value / max) * 100, 100);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div style={{ marginBottom: "12px" }}>
|
|
107
|
+
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "11px", marginBottom: "4px" }}>
|
|
108
|
+
<span style={{ fontWeight: "600" }}>{label}</span>
|
|
109
|
+
<span>{value.toFixed(2)}ms</span>
|
|
110
|
+
</div>
|
|
111
|
+
<div style={{ width: "100%", height: "8px", backgroundColor: "#e0e0e0", borderRadius: "4px", overflow: "hidden" }}>
|
|
112
|
+
<div
|
|
113
|
+
style={{
|
|
114
|
+
width: `${percentage}%`,
|
|
115
|
+
height: "100%",
|
|
116
|
+
backgroundColor: color,
|
|
117
|
+
transition: "width 0.3s ease",
|
|
118
|
+
}}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function formatBytes(bytes: number): string {
|
|
126
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
127
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
128
|
+
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
129
|
+
}
|