EnvEditor.tsx

132 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
import { useState, useCallback } from 'react';

interface EnvPair {
    key: string;
    value: string;
}

interface EnvEditorProps {
    env: string;       // JSON string like '{"KEY": "VALUE"}'
    actionUrl: string; // POST URL for saving
}

function parseEnv(env: string): EnvPair[] {
    try {
        const obj = JSON.parse(env || '{}');
        const pairs = Object.entries(obj).map(([key, value]) => ({
            key,
            value: String(value),
        }));
        return pairs.length > 0 ? pairs : [{ key: '', value: '' }];
    } catch {
        return [{ key: '', value: '' }];
    }
}

function serializeEnv(pairs: EnvPair[]): string {
    const obj: Record<string, string> = {};
    for (const p of pairs) {
        const k = p.key.trim();
        if (k) obj[k] = p.value;
    }
    return JSON.stringify(obj, null, 2);
}

export function EnvEditor({ env, actionUrl }: EnvEditorProps) {
    const [pairs, setPairs] = useState<EnvPair[]>(() => parseEnv(env));
    const [saving, setSaving] = useState(false);
    const [saved, setSaved] = useState(false);

    const update = useCallback((index: number, field: 'key' | 'value', val: string) => {
        setPairs(prev => prev.map((p, i) => i === index ? { ...p, [field]: val } : p));
        setSaved(false);
    }, []);

    const addRow = useCallback(() => {
        setPairs(prev => [...prev, { key: '', value: '' }]);
    }, []);

    const removeRow = useCallback((index: number) => {
        setPairs(prev => {
            const next = prev.filter((_, i) => i !== index);
            return next.length > 0 ? next : [{ key: '', value: '' }];
        });
        setSaved(false);
    }, []);

    const save = useCallback(async () => {
        setSaving(true);
        try {
            const body = new FormData();
            body.append('env', serializeEnv(pairs));
            const res = await fetch(actionUrl, { method: 'POST', body });
            if (res.ok) {
                setSaved(true);
                setTimeout(() => setSaved(false), 2000);
            }
        } finally {
            setSaving(false);
        }
    }, [pairs, actionUrl]);

    return (
        <div>
            <div className="space-y-1.5">
                {pairs.map((pair, i) => (
                    <div key={i} className="flex items-center gap-1.5">
                        <input
                            type="text"
                            value={pair.key}
                            onChange={e => update(i, 'key', e.target.value)}
                            placeholder="KEY"
                            spellCheck={false}
                            className="flex-1 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"
                        />
                        <input
                            type="text"
                            value={pair.value}
                            onChange={e => update(i, 'value', e.target.value)}
                            placeholder="value"
                            spellCheck={false}
                            className="flex-[1.5] 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"
                        />
                        <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>
            <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 variable
                </button>
                <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">Restart required after changes.</p>
        </div>
    );
}