feat: add bulk import and paste feature for .env and json configs
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getServerSession } from 'next-auth'
|
||||||
|
import { authOptions } from '@/lib/auth'
|
||||||
|
import sql from '@/lib/db'
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await getServerSession(authOptions)
|
||||||
|
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const body = await req.json()
|
||||||
|
const { environment_id, environment, entries, overwrite = true } = body
|
||||||
|
|
||||||
|
if (!environment_id || !Array.isArray(entries) || entries.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'environment_id ve en az bir config girişi gereklidir.' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = session.user?.name ?? 'admin'
|
||||||
|
const insertedOrUpdated: any[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql.begin(async (tx) => {
|
||||||
|
for (const item of entries) {
|
||||||
|
if (!item.key || item.value === undefined) continue
|
||||||
|
|
||||||
|
const formattedKey = item.key.trim().toUpperCase().replace(/\s+/g, '_')
|
||||||
|
const formattedValue = String(item.value).trim()
|
||||||
|
const formattedType = item.type || 'text'
|
||||||
|
const formattedDesc = item.description || null
|
||||||
|
|
||||||
|
if (overwrite) {
|
||||||
|
const [res] = await tx`
|
||||||
|
INSERT INTO config_entries (environment_id, key, value, type, description)
|
||||||
|
VALUES (${environment_id}, ${formattedKey}, ${formattedValue}, ${formattedType}, ${formattedDesc})
|
||||||
|
ON CONFLICT (environment_id, key)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
type = EXCLUDED.type,
|
||||||
|
description = COALESCE(EXCLUDED.description, config_entries.description),
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *
|
||||||
|
`
|
||||||
|
insertedOrUpdated.push(res)
|
||||||
|
} else {
|
||||||
|
const [res] = await tx`
|
||||||
|
INSERT INTO config_entries (environment_id, key, value, type, description)
|
||||||
|
VALUES (${environment_id}, ${formattedKey}, ${formattedValue}, ${formattedType}, ${formattedDesc})
|
||||||
|
ON CONFLICT (environment_id, key)
|
||||||
|
DO NOTHING
|
||||||
|
RETURNING *
|
||||||
|
`
|
||||||
|
if (res) insertedOrUpdated.push(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record batch audit log
|
||||||
|
if (insertedOrUpdated.length > 0) {
|
||||||
|
await tx`
|
||||||
|
INSERT INTO audit_logs (app_id, environment, action, key, actor)
|
||||||
|
VALUES (
|
||||||
|
${id},
|
||||||
|
${environment},
|
||||||
|
'create',
|
||||||
|
${`${insertedOrUpdated.length} adet config toplu aktarıldı`},
|
||||||
|
${actor}
|
||||||
|
)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
count: insertedOrUpdated.length,
|
||||||
|
entries: insertedOrUpdated,
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,14 @@ import { App, AppEnvironment, AuditLog, ConfigEntry, ConfigType, Environment } f
|
|||||||
import { cn, envBadgeStyles, maskSecret, typeColor } from '@/lib/utils'
|
import { cn, envBadgeStyles, maskSecret, typeColor } from '@/lib/utils'
|
||||||
import { AppIcon } from './AppIcon'
|
import { AppIcon } from './AppIcon'
|
||||||
import { IconPicker } from './IconPicker'
|
import { IconPicker } from './IconPicker'
|
||||||
|
import { BulkImportModal } from './BulkImportModal'
|
||||||
import {
|
import {
|
||||||
Eye, EyeOff, Copy, Plus, Trash2, Edit2, Check, X,
|
Eye, EyeOff, Copy, Plus, Trash2, Edit2, Check, X,
|
||||||
Key, RefreshCw, Clock, Search, Download, FileCode,
|
Key, RefreshCw, Clock, Search, Download, FileCode,
|
||||||
Shield, Code2, AlertTriangle, Terminal, Smartphone,
|
Shield, Code2, AlertTriangle, Terminal, Smartphone,
|
||||||
Globe, Sparkles, Filter, ChevronRight, CheckCircle2,
|
Globe, Sparkles, Filter, ChevronRight, CheckCircle2,
|
||||||
Lock, ExternalLink, HelpCircle, Settings, Settings2, ImageIcon
|
Lock, ExternalLink, HelpCircle, Settings, Settings2, ImageIcon,
|
||||||
|
FileText, ClipboardPaste
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -36,6 +38,7 @@ export function AppDetailClient({ app, environments, configs, auditLogs }: Props
|
|||||||
const [copiedSlug, setCopiedSlug] = useState(false)
|
const [copiedSlug, setCopiedSlug] = useState(false)
|
||||||
const [showAddModal, setShowAddModal] = useState(false)
|
const [showAddModal, setShowAddModal] = useState(false)
|
||||||
const [showEditModal, setShowEditModal] = useState(false)
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
const [showBulkModal, setShowBulkModal] = useState(false)
|
||||||
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set())
|
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set())
|
||||||
const [codeSnippetTab, setCodeSnippetTab] = useState<'rn' | 'next' | 'curl'>('rn')
|
const [codeSnippetTab, setCodeSnippetTab] = useState<'rn' | 'next' | 'curl'>('rn')
|
||||||
const [isDeletingApp, setIsDeletingApp] = useState(false)
|
const [isDeletingApp, setIsDeletingApp] = useState(false)
|
||||||
@@ -144,7 +147,7 @@ export function AppDetailClient({ app, environments, configs, auditLogs }: Props
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick App Actions */}
|
{/* Quick App Actions */}
|
||||||
<div className="flex items-center gap-2.5 self-start md:self-auto pt-2 md:pt-0">
|
<div className="flex flex-wrap items-center gap-2.5 self-start md:self-auto pt-2 md:pt-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEditModal(true)}
|
onClick={() => setShowEditModal(true)}
|
||||||
className="flex items-center gap-2 bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-slate-700 text-slate-300 hover:text-white font-semibold text-xs sm:text-sm px-3.5 py-2.5 rounded-xl transition-all"
|
className="flex items-center gap-2 bg-slate-900 hover:bg-slate-800 border border-slate-800 hover:border-slate-700 text-slate-300 hover:text-white font-semibold text-xs sm:text-sm px-3.5 py-2.5 rounded-xl transition-all"
|
||||||
@@ -154,6 +157,15 @@ export function AppDetailClient({ app, environments, configs, auditLogs }: Props
|
|||||||
<span>İkon / Ayarlar</span>
|
<span>İkon / Ayarlar</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkModal(true)}
|
||||||
|
className="flex items-center gap-2 bg-slate-900 hover:bg-slate-800 border border-emerald-500/30 hover:border-emerald-500/50 text-emerald-400 hover:text-emerald-300 font-semibold text-xs sm:text-sm px-3.5 py-2.5 rounded-xl transition-all shadow-sm"
|
||||||
|
title=".env veya JSON içeriğini toplu yapıştır"
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="w-4 h-4" />
|
||||||
|
<span>Toplu Yapıştır (.env)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddModal(true)}
|
onClick={() => setShowAddModal(true)}
|
||||||
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 text-slate-950 font-bold text-xs sm:text-sm px-4 py-2.5 rounded-xl shadow-lg shadow-emerald-500/20 transition-all hover:scale-[1.02]"
|
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 text-slate-950 font-bold text-xs sm:text-sm px-4 py-2.5 rounded-xl shadow-lg shadow-emerald-500/20 transition-all hover:scale-[1.02]"
|
||||||
@@ -250,8 +262,17 @@ export function AppDetailClient({ app, environments, configs, auditLogs }: Props
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Export Tools */}
|
{/* Quick Import & Export Tools */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkModal(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-emerald-500/10 border border-emerald-500/30 hover:bg-emerald-500/20 text-xs font-semibold text-emerald-400 hover:text-emerald-300 transition-colors shadow-sm"
|
||||||
|
title=".env veya JSON içeriğini toplu içe aktar"
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||||
|
<span>Toplu Yapıştır</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={exportAsEnv}
|
onClick={exportAsEnv}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 hover:border-slate-700 text-xs font-medium text-slate-300 hover:text-white transition-colors"
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 hover:border-slate-700 text-xs font-medium text-slate-300 hover:text-white transition-colors"
|
||||||
@@ -338,13 +359,23 @@ export function AppDetailClient({ app, environments, configs, auditLogs }: Props
|
|||||||
: 'Uygulamanızın ihtiyaç duyduğu API URL, Secret veya Flag değişkenlerini ekleyin.'}
|
: 'Uygulamanızın ihtiyaç duyduğu API URL, Secret veya Flag değişkenlerini ekleyin.'}
|
||||||
</p>
|
</p>
|
||||||
{!searchQuery && typeFilter === 'all' && (
|
{!searchQuery && typeFilter === 'all' && (
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddModal(true)}
|
onClick={() => setShowAddModal(true)}
|
||||||
className="inline-flex items-center gap-1.5 bg-emerald-500 hover:bg-emerald-400 text-slate-950 text-xs font-bold px-4 py-2 rounded-xl transition-transform hover:scale-105 shadow-md shadow-emerald-500/20"
|
className="inline-flex items-center gap-1.5 bg-emerald-500 hover:bg-emerald-400 text-slate-950 text-xs font-bold px-4 py-2.5 rounded-xl transition-transform hover:scale-105 shadow-md shadow-emerald-500/20"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 stroke-[2.5]" />
|
<Plus className="w-3.5 h-3.5 stroke-[2.5]" />
|
||||||
<span>İlk Değişkeni Ekle</span>
|
<span>Tek Tek Ekle</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBulkModal(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 bg-slate-900 hover:bg-slate-800 border border-emerald-500/30 hover:border-emerald-500/50 text-emerald-400 hover:text-emerald-300 text-xs font-semibold px-4 py-2.5 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||||
|
<span>Toplu Yapıştır (.env / JSON)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -656,6 +687,21 @@ export async function getAppConfig(env = 'production') {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ─── BULK IMPORT / PASTE MODAL ─── */}
|
||||||
|
{showBulkModal && (
|
||||||
|
<BulkImportModal
|
||||||
|
environmentId={currentEnv?.id ?? ''}
|
||||||
|
environment={activeEnv}
|
||||||
|
appId={app.id}
|
||||||
|
existingKeys={envConfigs.map(c => c.key)}
|
||||||
|
onClose={() => setShowBulkModal(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setShowBulkModal(false)
|
||||||
|
router.refresh()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> •
|
||||||
|
.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user