Files

514 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}