VolumeEditor.tsx
154 lines1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import { useState, useCallback } from 'react';
interface VolumeMount {
host: string;
container: string;
}
interface VolumeEditorProps {
volumes: string; // JSON string like '[{"host":"/mnt/data/x","container":"/data"}]'
actionUrl: string; // POST URL for saving
fieldName?: string; // Form field name (default "volumes")
inline?: boolean; // If true, renders as hidden input for embedding in a parent form (no save button)
}
function parseVolumes(volumes: string): VolumeMount[] {
try {
const arr = JSON.parse(volumes || '[]');
if (Array.isArray(arr) && arr.length > 0) {
return arr.map((v: any) => ({
host: String(v.host || ''),
container: String(v.container || ''),
}));
}
return [{ host: '', container: '' }];
} catch {
return [{ host: '', container: '' }];
}
}
function serializeVolumes(mounts: VolumeMount[]): string {
const valid = mounts.filter(m => m.host.trim() || m.container.trim());
if (valid.length === 0) return '';
return JSON.stringify(valid.map(m => ({
host: m.host.trim(),
container: m.container.trim(),
})));
}
export function VolumeEditor({ volumes, actionUrl, fieldName = 'volumes', inline = false }: VolumeEditorProps) {
const [mounts, setMounts] = useState<VolumeMount[]>(() => parseVolumes(volumes));
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const update = useCallback((index: number, field: 'host' | 'container', val: string) => {
setMounts(prev => prev.map((m, i) => i === index ? { ...m, [field]: val } : m));
setSaved(false);
}, []);
const addRow = useCallback(() => {
setMounts(prev => [...prev, { host: '', container: '' }]);
}, []);
const removeRow = useCallback((index: number) => {
setMounts(prev => {
const next = prev.filter((_, i) => i !== index);
return next.length > 0 ? next : [{ host: '', container: '' }];
});
setSaved(false);
}, []);
const save = useCallback(async () => {
setSaving(true);
try {
const body = new FormData();
body.append(fieldName, serializeVolumes(mounts));
const res = await fetch(actionUrl, { method: 'POST', body });
if (res.ok) {
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
} finally {
setSaving(false);
}
}, [mounts, actionUrl, fieldName]);
const serialized = serializeVolumes(mounts);
return (
<div>
<div className="space-y-1.5">
{mounts.map((mount, i) => (
<div key={i} className="flex items-center gap-1.5">
<div className="flex-1 min-w-0">
<input
type="text"
value={mount.host}
onChange={e => update(i, 'host', e.target.value)}
placeholder="/mnt/data/myapp"
spellCheck={false}
className="w-full font-mono text-[12px] text-slate-600 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:border-indigo-300 focus:ring-1 focus:ring-indigo-100 transition-colors placeholder:text-slate-300"
/>
<span className="text-[9px] text-slate-300 ml-1">host path</span>
</div>
<svg xmlns="http://www.w3.org/2000/svg" className="w-3 h-3 text-slate-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
<div className="flex-1 min-w-0">
<input
type="text"
value={mount.container}
onChange={e => update(i, 'container', e.target.value)}
placeholder="/data"
spellCheck={false}
className="w-full font-mono text-[12px] text-slate-600 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:border-indigo-300 focus:ring-1 focus:ring-indigo-100 transition-colors placeholder:text-slate-300"
/>
<span className="text-[9px] text-slate-300 ml-1">container path</span>
</div>
<button
type="button"
onClick={() => removeRow(i)}
className="shrink-0 w-7 h-7 flex items-center justify-center rounded-lg text-slate-300 hover:text-red-400 hover:bg-red-50 transition-colors"
title="Remove"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
{inline && (
<input type="hidden" name={fieldName} value={serialized} />
)}
<div className="flex items-center justify-between mt-2.5">
<button
type="button"
onClick={addRow}
className="inline-flex items-center gap-1 text-[11px] font-medium text-slate-400 hover:text-indigo-500 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
Add volume
</button>
{!inline && (
<div className="flex items-center gap-2">
{saved && <span className="text-[10px] text-emerald-500 font-medium">Saved</span>}
<button
type="button"
onClick={save}
disabled={saving}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-[11px] font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
)}
</div>
<p className="text-[10px] text-slate-400 mt-1.5">Host paths under <code className="text-indigo-500">/mnt/data/</code> persist across redeploys.</p>
</div>
);
}