feat: add bulk import and paste feature for .env and json configs

This commit is contained in:
2026-08-26 15:37:49 +03:00
parent fef66cd9dd
commit ac00ae1e78
3 changed files with 654 additions and 10 deletions
+513
View File
@@ -0,0 +1,513 @@
'use client'
import { useState, useMemo, useRef } from 'react'
import { ConfigType, Environment } from '@/types'
import { cn, typeColor } from '@/lib/utils'
import {
FileText, Upload, Sparkles, AlertCircle, Check,
X, Trash2, ArrowRight, Loader2, Key, Database, RefreshCw
} from 'lucide-react'
interface BulkImportModalProps {
environmentId: string
environment: Environment
appId: string
existingKeys: string[]
onClose: () => void
onSaved: () => void
}
interface ParsedEntry {
id: string
key: string
value: string
type: ConfigType
description?: string
isConflict: boolean
}
// Smart type detector
function detectType(key: string, value: string): ConfigType {
const upperKey = key.toUpperCase()
const lowerVal = value.toLowerCase().trim()
if (
upperKey.includes('SECRET') ||
upperKey.includes('KEY') ||
upperKey.includes('TOKEN') ||
upperKey.includes('PASSWORD') ||
upperKey.includes('AUTH') ||
upperKey.includes('PRIVATE') ||
upperKey.includes('CERT') ||
upperKey.includes('SIGNING')
) {
return 'secret'
}
if (lowerVal.startsWith('http://') || lowerVal.startsWith('https://')) {
return 'url'
}
if (lowerVal === 'true' || lowerVal === 'false') {
return 'boolean'
}
if (/^-?\d+(\.\d+)?$/.test(lowerVal) && !lowerVal.startsWith('0') && lowerVal.length < 15) {
return 'number'
}
if (
(lowerVal.startsWith('{') && lowerVal.endsWith('}')) ||
(lowerVal.startsWith('[') && lowerVal.endsWith(']'))
) {
try {
JSON.parse(value)
return 'json'
} catch {
// not valid JSON
}
}
return 'text'
}
export function BulkImportModal({
environmentId,
environment,
appId,
existingKeys,
onClose,
onSaved,
}: BulkImportModalProps) {
const [rawText, setRawText] = useState('')
const [overwrite, setOverwrite] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [customParsed, setCustomParsed] = useState<ParsedEntry[] | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const existingKeysSet = useMemo(() => new Set(existingKeys.map(k => k.toUpperCase())), [existingKeys])
// Parse raw text into structured entries
const parsedEntries = useMemo(() => {
if (customParsed !== null) return customParsed
if (!rawText.trim()) return []
const text = rawText.trim()
// 1. Try JSON parsing
if (text.startsWith('{') && text.endsWith('}')) {
try {
const obj = JSON.parse(text)
if (typeof obj === 'object' && obj !== null) {
const list: ParsedEntry[] = []
Object.entries(obj).forEach(([k, v], idx) => {
const strVal = typeof v === 'object' ? JSON.stringify(v) : String(v)
const cleanKey = k.trim().toUpperCase().replace(/\s+/g, '_')
list.push({
id: `json-${idx}-${cleanKey}`,
key: cleanKey,
value: strVal,
type: detectType(cleanKey, strVal),
isConflict: existingKeysSet.has(cleanKey),
})
})
return list
}
} catch {
// Fallback to line by line .env parsing
}
}
// 2. Line by line .env format parsing
const lines = text.split(/\r?\n/)
const list: ParsedEntry[] = []
let pendingComment = ''
lines.forEach((line, idx) => {
const trimmed = line.trim()
if (!trimmed) {
pendingComment = ''
return
}
// Comment line
if (trimmed.startsWith('#')) {
pendingComment = trimmed.replace(/^#+\s*/, '')
return
}
// Check if export PREFIX exists (e.g. export KEY=VALUE)
let lineToParse = trimmed
if (lineToParse.startsWith('export ')) {
lineToParse = lineToParse.slice(7).trim()
}
// Split at first `=`
const eqIdx = lineToParse.indexOf('=')
if (eqIdx !== -1) {
const k = lineToParse.slice(0, eqIdx).trim()
let v = lineToParse.slice(eqIdx + 1).trim()
// Strip surrounding quotes if matching: "value" or 'value'
if (
(v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
(v.startsWith("'") && v.endsWith("'") && v.length >= 2)
) {
v = v.slice(1, -1)
}
const cleanKey = k.toUpperCase().replace(/\s+/g, '_')
if (cleanKey) {
list.push({
id: `line-${idx}-${cleanKey}`,
key: cleanKey,
value: v,
type: detectType(cleanKey, v),
description: pendingComment || undefined,
isConflict: existingKeysSet.has(cleanKey),
})
pendingComment = ''
}
}
})
return list
}, [rawText, customParsed, existingKeysSet])
function handleTypeChange(id: string, newType: ConfigType) {
const updated = parsedEntries.map(e => e.id === id ? { ...e, type: newType } : e)
setCustomParsed(updated)
}
function handleKeyChange(id: string, newKey: string) {
const cleanKey = newKey.toUpperCase().replace(/\s+/g, '_')
const updated = parsedEntries.map(e => e.id === id ? { ...e, key: cleanKey, isConflict: existingKeysSet.has(cleanKey) } : e)
setCustomParsed(updated)
}
function handleValueChange(id: string, newVal: string) {
const updated = parsedEntries.map(e => e.id === id ? { ...e, value: newVal } : e)
setCustomParsed(updated)
}
function handleRemove(id: string) {
const updated = parsedEntries.filter(e => e.id !== id)
setCustomParsed(updated)
}
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
setCustomParsed(null)
setRawText(reader.result)
}
}
reader.readAsText(file)
}
async function handleImport() {
if (parsedEntries.length === 0) return
setLoading(true)
setError('')
try {
const res = await fetch(`/api/apps/${appId}/config/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
environment_id: environmentId,
environment,
overwrite,
entries: parsedEntries.map(e => ({
key: e.key,
value: e.value,
type: e.type,
description: e.description,
})),
}),
})
const data = await res.json()
setLoading(false)
if (!res.ok) {
setError(data.error || 'İçe aktarma sırasında bir hata oluştu.')
return
}
onSaved()
} catch {
setLoading(false)
setError('Ağ hatası oluştu. Lütfen tekrar deneyin.')
}
}
const conflictsCount = parsedEntries.filter(e => e.isConflict).length
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-md animate-fade-in">
<div className="glass-panel w-full max-w-3xl rounded-3xl p-6 sm:p-8 shadow-2xl border border-slate-700/80 relative overflow-hidden flex flex-col max-h-[90vh]">
{/* Top glow accent */}
<div className="absolute top-0 left-0 right-0 h-[1px] bg-gradient-to-r from-transparent via-emerald-500/60 to-transparent" />
{/* Modal Header */}
<div className="flex items-start justify-between pb-4 border-b border-slate-800 flex-shrink-0">
<div>
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<FileText className="w-5 h-5 text-emerald-400" />
<span>Toplu Config & Secret Yapıştır / İçe Aktar</span>
</h3>
<p className="text-slate-400 text-xs mt-1">
Hedef Ortam: <span className="text-emerald-400 font-semibold uppercase">{environment}</span> &bull;
.env veya JSON formatında doğrudan yapıştırabilirsiniz.
</p>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-xl bg-slate-900 text-slate-400 hover:text-white border border-slate-800 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Modal Body */}
<div className="flex-1 overflow-y-auto py-5 space-y-5">
{/* Paste Input Area */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-xs font-semibold text-slate-300 uppercase tracking-wider flex items-center gap-1.5">
<span>.env veya JSON İçeriğini Buraya Yapıştırın</span>
</label>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
accept=".env,.env.*,.txt,.json"
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-xs text-emerald-400 hover:text-emerald-300 flex items-center gap-1 px-2.5 py-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 hover:bg-emerald-500/20 transition-colors"
>
<Upload className="w-3.5 h-3.5" />
<span>Dosyadan Yükle (.env / JSON)</span>
</button>
{rawText && (
<button
type="button"
onClick={() => {
setRawText('')
setCustomParsed(null)
}}
className="text-xs text-slate-400 hover:text-white"
>
Temizle
</button>
)}
</div>
</div>
<textarea
value={rawText}
onChange={e => {
setCustomParsed(null)
setRawText(e.target.value)
}}
placeholder={`Örnek .env formatı:
# Supabase bağlantısı
SUPABASE_URL=https://xyz.supabase.co
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsIn...
API_ENDPOINT=https://api.myapp.com
ENABLE_DARK_MODE=true
TIMEOUT_MS=5000`}
rows={5}
className="w-full glass-input rounded-2xl p-4 text-xs sm:text-sm font-mono text-emerald-300 placeholder-slate-600 focus:outline-none resize-none leading-relaxed"
/>
</div>
{/* Parsed Preview Table */}
{parsedEntries.length > 0 && (
<div className="space-y-3 animate-fade-in">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-300 uppercase tracking-wider">
Ayrıştırılan Değişkenler
</span>
<span className="px-2 py-0.5 rounded-full text-xs font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
{parsedEntries.length} Değişken Bulundu
</span>
</div>
{conflictsCount > 0 && (
<span className="text-xs text-amber-400 flex items-center gap-1">
<AlertCircle className="w-3.5 h-3.5" />
<span>{conflictsCount} değişken zaten mevcut</span>
</span>
)}
</div>
{/* Table */}
<div className="glass-panel rounded-2xl overflow-hidden border border-slate-800 max-h-60 overflow-y-auto">
<table className="w-full text-left text-xs">
<thead className="bg-slate-900/90 border-b border-slate-800 text-[10px] font-semibold text-slate-400 uppercase tracking-wider sticky top-0 z-10">
<tr>
<th className="px-4 py-2.5">Anahtar (Key)</th>
<th className="px-3 py-2.5">Tip</th>
<th className="px-4 py-2.5">Değer (Value)</th>
<th className="px-3 py-2.5 text-right">Sil</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{parsedEntries.map((entry) => (
<tr key={entry.id} className="hover:bg-slate-800/30 transition-colors">
{/* Key */}
<td className="px-4 py-2">
<div className="flex items-center gap-1.5">
<input
value={entry.key}
onChange={e => handleKeyChange(entry.id, e.target.value)}
className="bg-transparent text-white font-mono font-bold text-xs focus:bg-slate-900 px-1 py-0.5 rounded focus:outline-none border border-transparent focus:border-slate-700 w-full"
/>
{entry.isConflict && (
<span
className="text-[10px] px-1.5 py-0.2 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20 whitespace-nowrap"
title="Bu anahtar zaten mevcut"
>
Mevcut
</span>
)}
</div>
{entry.description && (
<span className="text-[10px] text-slate-500 block truncate pl-1">
# {entry.description}
</span>
)}
</td>
{/* Type */}
<td className="px-3 py-2">
<select
value={entry.type}
onChange={e => handleTypeChange(entry.id, e.target.value as ConfigType)}
className="bg-slate-900 border border-slate-700/80 rounded-lg px-2 py-1 text-[11px] text-slate-200 focus:outline-none"
>
<option value="text">text</option>
<option value="secret">secret</option>
<option value="url">url</option>
<option value="json">json</option>
<option value="boolean">boolean</option>
<option value="number">number</option>
</select>
</td>
{/* Value */}
<td className="px-4 py-2 max-w-xs truncate">
<input
value={entry.value}
onChange={e => handleValueChange(entry.id, e.target.value)}
className="bg-transparent text-emerald-300 font-mono text-xs focus:bg-slate-900 px-1 py-0.5 rounded focus:outline-none border border-transparent focus:border-slate-700 w-full truncate"
/>
</td>
{/* Actions */}
<td className="px-3 py-2 text-right">
<button
type="button"
onClick={() => handleRemove(entry.id)}
className="text-slate-500 hover:text-rose-400 p-1 rounded transition-colors"
title="Bu satırı kaldır"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Conflict Mode Selection */}
{conflictsCount > 0 && (
<div className="p-3.5 rounded-2xl bg-amber-500/5 border border-amber-500/20 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
<span className="text-amber-300 font-medium">
Çakışan ({conflictsCount}) anahtar bulundu. Ne yapılsın?
</span>
<div className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 cursor-pointer text-slate-300 hover:text-white">
<input
type="radio"
name="conflict"
checked={overwrite}
onChange={() => setOverwrite(true)}
className="text-emerald-500 focus:ring-emerald-500"
/>
<span>Mevcut Olanları Güncelle</span>
</label>
<label className="inline-flex items-center gap-1.5 cursor-pointer text-slate-300 hover:text-white">
<input
type="radio"
name="conflict"
checked={!overwrite}
onChange={() => setOverwrite(false)}
className="text-emerald-500 focus:ring-emerald-500"
/>
<span>Mevcut Olanları Atla</span>
</label>
</div>
</div>
)}
</div>
)}
{error && (
<div className="bg-rose-500/10 border border-rose-500/30 rounded-2xl p-3.5 text-rose-300 text-xs flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
</div>
{/* Modal Footer */}
<div className="flex items-center gap-3 pt-4 border-t border-slate-800 flex-shrink-0">
<button
type="button"
onClick={onClose}
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold text-xs sm:text-sm py-3 rounded-xl transition-colors"
>
İptal
</button>
<button
type="button"
onClick={handleImport}
disabled={loading || parsedEntries.length === 0}
className="flex-1 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 disabled:opacity-50 disabled:cursor-not-allowed text-slate-950 font-bold text-xs sm:text-sm py-3 rounded-xl transition-all shadow-lg shadow-emerald-500/25 flex items-center justify-center gap-2"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>İçe Aktarılıyor...</span>
</>
) : (
<>
<span>{parsedEntries.length > 0 ? `${parsedEntries.length} Değişkeni İçe Aktar` : 'İçe Aktar'}</span>
<ArrowRight className="w-4 h-4 stroke-[2.5]" />
</>
)}
</button>
</div>
</div>
</div>
)
}