VolumeEditor.tsx

154 lines
1 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>
    );
}