1107 lines
47 KiB
TypeScript
1107 lines
47 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useMemo } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import Link from 'next/link'
|
||
import { App, AppEnvironment, AuditLog, ConfigEntry, ConfigType, Environment } from '@/types'
|
||
import { cn, envBadgeStyles, maskSecret, typeColor } from '@/lib/utils'
|
||
import { AppIcon } from './AppIcon'
|
||
import { IconPicker } from './IconPicker'
|
||
import {
|
||
Eye, EyeOff, Copy, Plus, Trash2, Edit2, Check, X,
|
||
Key, RefreshCw, Clock, Search, Download, FileCode,
|
||
Shield, Code2, AlertTriangle, Terminal, Smartphone,
|
||
Globe, Sparkles, Filter, ChevronRight, CheckCircle2,
|
||
Lock, ExternalLink, HelpCircle, Settings, Settings2, ImageIcon
|
||
} from 'lucide-react'
|
||
|
||
interface Props {
|
||
app: App
|
||
environments: AppEnvironment[]
|
||
configs: ConfigEntry[]
|
||
auditLogs: AuditLog[]
|
||
}
|
||
|
||
type Tab = 'config' | 'api' | 'audit'
|
||
|
||
export function AppDetailClient({ app, environments, configs, auditLogs }: Props) {
|
||
const router = useRouter()
|
||
const [activeEnv, setActiveEnv] = useState<Environment>('production')
|
||
const [activeTab, setActiveTab] = useState<Tab>('config')
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||
const [showApiKey, setShowApiKey] = useState(false)
|
||
const [copiedKey, setCopiedKey] = useState(false)
|
||
const [copiedEnv, setCopiedEnv] = useState(false)
|
||
const [copiedSlug, setCopiedSlug] = useState(false)
|
||
const [showAddModal, setShowAddModal] = useState(false)
|
||
const [showEditModal, setShowEditModal] = useState(false)
|
||
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set())
|
||
const [codeSnippetTab, setCodeSnippetTab] = useState<'rn' | 'next' | 'curl'>('rn')
|
||
const [isDeletingApp, setIsDeletingApp] = useState(false)
|
||
|
||
const currentEnv = environments.find(e => e.name === activeEnv)
|
||
|
||
// Filter configs for active environment, search query and type filter
|
||
const envConfigs = useMemo(() => {
|
||
return configs.filter(c => c.environment_id === currentEnv?.id)
|
||
}, [configs, currentEnv])
|
||
|
||
const filteredConfigs = useMemo(() => {
|
||
return envConfigs.filter(c => {
|
||
const matchesSearch =
|
||
c.key.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
(c.description && c.description.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||
(c.type !== 'secret' && c.value.toLowerCase().includes(searchQuery.toLowerCase()))
|
||
|
||
const matchesType = typeFilter === 'all' || c.type === typeFilter
|
||
return matchesSearch && matchesType
|
||
})
|
||
}, [envConfigs, searchQuery, typeFilter])
|
||
|
||
function copyToClipboard(text: string, setCopied?: (v: boolean) => void) {
|
||
navigator.clipboard.writeText(text)
|
||
if (setCopied) {
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
}
|
||
}
|
||
|
||
function exportAsEnv() {
|
||
const lines = envConfigs.map(c => {
|
||
const comment = c.description ? `# ${c.description}\n` : ''
|
||
return `${comment}${c.key}="${c.value.replace(/"/g, '\\"')}"`
|
||
}).join('\n')
|
||
copyToClipboard(lines, setCopiedEnv)
|
||
}
|
||
|
||
function exportAsJson() {
|
||
const obj: Record<string, string> = {}
|
||
envConfigs.forEach(c => {
|
||
obj[c.key] = c.value
|
||
})
|
||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(obj, null, 2))
|
||
const downloadAnchor = document.createElement('a')
|
||
downloadAnchor.setAttribute("href", dataStr)
|
||
downloadAnchor.setAttribute("download", `${app.slug}-${activeEnv}-config.json`)
|
||
document.body.appendChild(downloadAnchor)
|
||
downloadAnchor.click()
|
||
downloadAnchor.remove()
|
||
}
|
||
|
||
async function rotateApiKey() {
|
||
if (!confirm('API anahtarını yenilemek istediğinize emin misiniz? Bu anahtarı kullanan tüm uygulamalar yenilenene kadar çalışmayacaktır.')) return
|
||
await fetch(`/api/apps/${app.id}/rotate-key`, { method: 'POST' })
|
||
router.refresh()
|
||
}
|
||
|
||
async function handleDeleteApp() {
|
||
const confirmation = prompt(`Bu uygulamayı ve tüm ayarlarını silmek için lütfen uygulamanın slug değerini yazın: "${app.slug}"`)
|
||
if (confirmation !== app.slug) {
|
||
alert('Slug eşleşmedi, silme işlemi iptal edildi.')
|
||
return
|
||
}
|
||
setIsDeletingApp(true)
|
||
await fetch(`/api/apps/${app.id}`, { method: 'DELETE' })
|
||
router.push('/dashboard')
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{/* ─── APP HEADER ─── */}
|
||
<div className="glass-panel p-6 sm:p-7 rounded-3xl relative overflow-hidden flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||
<div className="flex items-start sm:items-center gap-4">
|
||
<div className="relative group cursor-pointer" onClick={() => setShowEditModal(true)} title="İkonu Değiştir">
|
||
<AppIcon name={app.name} iconUrl={app.icon_url} size="xl" className="ring-2 ring-emerald-500/20 group-hover:ring-emerald-400 transition-all" />
|
||
<div className="absolute inset-0 bg-black/60 rounded-3xl opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity">
|
||
<Edit2 className="w-4 h-4 text-emerald-400" />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex flex-wrap items-center gap-2.5">
|
||
<h1 className="text-2xl sm:text-3xl font-extrabold text-white tracking-tight">
|
||
{app.name}
|
||
</h1>
|
||
<button
|
||
onClick={() => copyToClipboard(app.slug, setCopiedSlug)}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-slate-900/90 border border-slate-800 text-xs font-mono text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors"
|
||
title="Slug'ı Kopyala"
|
||
>
|
||
<span>/{app.slug}</span>
|
||
{copiedSlug ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
||
</button>
|
||
</div>
|
||
|
||
{app.description ? (
|
||
<p className="text-slate-400 text-sm mt-1.5 max-w-2xl leading-relaxed">
|
||
{app.description}
|
||
</p>
|
||
) : (
|
||
<p className="text-slate-600 text-xs mt-1 italic">Açıklama bulunmuyor</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quick App Actions */}
|
||
<div className="flex items-center gap-2.5 self-start md:self-auto pt-2 md:pt-0">
|
||
<button
|
||
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"
|
||
title="İkon ve Uygulama Bilgilerini Düzenle"
|
||
>
|
||
<Settings className="w-4 h-4" />
|
||
<span>İkon / Ayarlar</span>
|
||
</button>
|
||
|
||
<button
|
||
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]"
|
||
>
|
||
<Plus className="w-4 h-4 stroke-[2.5]" />
|
||
<span>Config Ekle</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ─── NAVIGATION TABS ─── */}
|
||
<div className="flex items-center justify-between border-b border-slate-800/80 pb-px">
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setActiveTab('config')}
|
||
className={cn(
|
||
'flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-semibold transition-all -mb-px',
|
||
activeTab === 'config'
|
||
? 'border-emerald-400 text-emerald-400'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
|
||
)}
|
||
>
|
||
<FileCode className="w-4 h-4" />
|
||
<span>Değişkenler & Secretlar</span>
|
||
<span className="ml-1 px-2 py-0.5 rounded-full text-[11px] bg-slate-800 text-slate-300 font-mono">
|
||
{envConfigs.length}
|
||
</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setActiveTab('api')}
|
||
className={cn(
|
||
'flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-semibold transition-all -mb-px',
|
||
activeTab === 'api'
|
||
? 'border-emerald-400 text-emerald-400'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
|
||
)}
|
||
>
|
||
<Code2 className="w-4 h-4" />
|
||
<span>API & Entegrasyon</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => setActiveTab('audit')}
|
||
className={cn(
|
||
'flex items-center gap-2 px-4 py-3 border-b-2 text-sm font-semibold transition-all -mb-px',
|
||
activeTab === 'audit'
|
||
? 'border-emerald-400 text-emerald-400'
|
||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700'
|
||
)}
|
||
>
|
||
<Clock className="w-4 h-4" />
|
||
<span>İşlem Geçmişi (Audit)</span>
|
||
<span className="ml-1 px-2 py-0.5 rounded-full text-[11px] bg-slate-800 text-slate-300 font-mono">
|
||
{auditLogs.length}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ─── TAB 1: CONFIG ENTRIES ─── */}
|
||
{activeTab === 'config' && (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{/* Environment Switcher & Top Toolbar */}
|
||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||
{/* Environment Pills */}
|
||
<div className="flex items-center gap-2 p-1.5 rounded-2xl bg-slate-900/90 border border-slate-800/80 w-fit">
|
||
{environments.map(env => {
|
||
const styles = envBadgeStyles(env.name)
|
||
const isActive = activeEnv === env.name
|
||
const count = configs.filter(c => c.environment_id === env.id).length
|
||
|
||
return (
|
||
<button
|
||
key={env.id}
|
||
onClick={() => setActiveEnv(env.name)}
|
||
className={cn(
|
||
'flex items-center gap-2 px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all',
|
||
isActive
|
||
? styles.active
|
||
: 'text-slate-400 hover:text-white hover:bg-slate-800/50'
|
||
)}
|
||
>
|
||
<span className={cn('w-1.5 h-1.5 rounded-full', isActive ? 'bg-slate-950' : styles.dot)} />
|
||
<span className="capitalize">{env.name}</span>
|
||
<span className={cn(
|
||
'text-[10px] px-1.5 py-0.2 rounded-md font-mono',
|
||
isActive ? 'bg-black/20 text-slate-950' : 'bg-slate-800 text-slate-400'
|
||
)}>
|
||
{count}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Quick Export Tools */}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button
|
||
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"
|
||
title=".env formatında kopyala"
|
||
>
|
||
{copiedEnv ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||
<span>{copiedEnv ? 'Kopyalandı!' : '.env Kopyala'}</span>
|
||
</button>
|
||
|
||
<button
|
||
onClick={exportAsJson}
|
||
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"
|
||
title="JSON olarak indir"
|
||
>
|
||
<Download className="w-3.5 h-3.5" />
|
||
<span>JSON İndir</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search & Type Filter Bar */}
|
||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
||
<div className="relative flex-1 max-w-md">
|
||
<Search className="w-4 h-4 text-slate-500 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
value={searchQuery}
|
||
onChange={e => setSearchQuery(e.target.value)}
|
||
placeholder="Key, açıklama veya değer ara..."
|
||
className="w-full glass-input rounded-xl pl-10 pr-4 py-2 text-xs sm:text-sm text-white placeholder-slate-500 focus:outline-none"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
onClick={() => setSearchQuery('')}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-400 hover:text-white"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Filter className="w-3.5 h-3.5 text-slate-500" />
|
||
<select
|
||
value={typeFilter}
|
||
onChange={e => setTypeFilter(e.target.value)}
|
||
className="bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-slate-300 focus:outline-none focus:border-emerald-500/50"
|
||
>
|
||
<option value="all">Tüm Tipler</option>
|
||
<option value="text">Text</option>
|
||
<option value="secret">Secret (Gizli)</option>
|
||
<option value="url">URL</option>
|
||
<option value="json">JSON</option>
|
||
<option value="boolean">Boolean</option>
|
||
<option value="number">Number</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Config Table Card */}
|
||
<div className="glass-panel rounded-3xl overflow-hidden shadow-xl">
|
||
{/* Header row */}
|
||
<div className="hidden sm:grid grid-cols-12 gap-4 px-6 py-3.5 bg-slate-900/80 border-b border-slate-800/80 text-[11px] font-semibold text-slate-400 uppercase tracking-wider">
|
||
<div className="col-span-4">Anahtar (Key) & Açıklama</div>
|
||
<div className="col-span-2">Tip</div>
|
||
<div className="col-span-4">Değer (Value)</div>
|
||
<div className="col-span-2 text-right">İşlemler</div>
|
||
</div>
|
||
|
||
{/* Empty State */}
|
||
{filteredConfigs.length === 0 && (
|
||
<div className="text-center py-16 px-4">
|
||
<div className="w-12 h-12 rounded-2xl bg-slate-900 border border-slate-800 flex items-center justify-center mx-auto mb-3 text-slate-500">
|
||
<Key className="w-6 h-6" />
|
||
</div>
|
||
<h3 className="text-white text-sm font-semibold mb-1">
|
||
{searchQuery || typeFilter !== 'all'
|
||
? 'Filtreyle eşleşen config bulunamadı'
|
||
: `${activeEnv.toUpperCase()} ortamında henüz değişken yok`}
|
||
</h3>
|
||
<p className="text-slate-500 text-xs max-w-sm mx-auto mb-5">
|
||
{searchQuery || typeFilter !== 'all'
|
||
? 'Arama kriterlerinizi temizleyerek tekrar deneyin.'
|
||
: 'Uygulamanızın ihtiyaç duyduğu API URL, Secret veya Flag değişkenlerini ekleyin.'}
|
||
</p>
|
||
{!searchQuery && typeFilter === 'all' && (
|
||
<button
|
||
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"
|
||
>
|
||
<Plus className="w-3.5 h-3.5 stroke-[2.5]" />
|
||
<span>İlk Değişkeni Ekle</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Rows */}
|
||
{filteredConfigs.map(entry => (
|
||
<ConfigRow
|
||
key={entry.id}
|
||
entry={entry}
|
||
appId={app.id}
|
||
environment={activeEnv}
|
||
revealed={revealedIds.has(entry.id)}
|
||
onToggleReveal={() => {
|
||
const next = new Set(revealedIds)
|
||
if (next.has(entry.id)) next.delete(entry.id)
|
||
else next.add(entry.id)
|
||
setRevealedIds(next)
|
||
}}
|
||
onDeleted={() => router.refresh()}
|
||
onSaved={() => router.refresh()}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ─── TAB 2: API & INTEGRATION ─── */}
|
||
{activeTab === 'api' && (
|
||
<div className="space-y-6 animate-fade-in">
|
||
{/* API Key Box */}
|
||
<div className="glass-panel p-6 sm:p-7 rounded-3xl space-y-6">
|
||
<div>
|
||
<h2 className="text-lg font-bold text-white flex items-center gap-2">
|
||
<Key className="w-5 h-5 text-emerald-400" />
|
||
<span>Uygulama API Anahtarı (X-Api-Key)</span>
|
||
</h2>
|
||
<p className="text-slate-400 text-xs sm:text-sm mt-1">
|
||
Mobil uygulamanız veya backend servisiniz başlangıçta config verilerini güvenle çekmek için bu anahtarı kullanır.
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2">
|
||
API Key
|
||
</label>
|
||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||
<div className="flex-1 glass-input rounded-xl px-4 py-3 font-mono text-xs sm:text-sm text-emerald-300 break-all select-all flex items-center justify-between">
|
||
<span>{showApiKey ? app.api_key : maskSecret(app.api_key)}</span>
|
||
{showApiKey && (
|
||
<span className="text-[10px] uppercase font-bold text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
|
||
Aktif
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||
<button
|
||
onClick={() => setShowApiKey(!showApiKey)}
|
||
className="p-3 bg-slate-900 border border-slate-800 hover:border-slate-700 rounded-xl text-slate-400 hover:text-white transition-colors"
|
||
title={showApiKey ? 'Gizle' : 'Göster'}
|
||
>
|
||
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||
</button>
|
||
<button
|
||
onClick={() => copyToClipboard(app.api_key, setCopiedKey)}
|
||
className="flex items-center gap-1.5 px-4 py-3 bg-emerald-500 hover:bg-emerald-400 text-slate-950 rounded-xl font-bold text-xs transition-colors shadow-lg shadow-emerald-500/20"
|
||
>
|
||
{copiedKey ? <Check className="w-4 h-4 stroke-[2.5]" /> : <Copy className="w-4 h-4 stroke-[2.5]" />}
|
||
<span>{copiedKey ? 'Kopyalandı' : 'Kopyala'}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Code Snippets Accordion / Tabs */}
|
||
<div className="border-t border-slate-800/80 pt-6 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h3 className="text-white font-bold text-sm sm:text-base flex items-center gap-2">
|
||
<Sparkles className="w-4 h-4 text-emerald-400" />
|
||
<span>Hızlı Entegrasyon Kodları</span>
|
||
</h3>
|
||
<p className="text-slate-400 text-xs mt-0.5">
|
||
Projenize doğrudan yapıştırabileceğiniz hazır SDK ve istemci fonksiyonları:
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex p-1 rounded-xl bg-slate-900 border border-slate-800">
|
||
<button
|
||
onClick={() => setCodeSnippetTab('rn')}
|
||
className={cn(
|
||
'px-3 py-1 rounded-lg text-xs font-semibold transition-all flex items-center gap-1.5',
|
||
codeSnippetTab === 'rn' ? 'bg-slate-800 text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-white'
|
||
)}
|
||
>
|
||
<Smartphone className="w-3.5 h-3.5" />
|
||
<span>React Native / Expo</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setCodeSnippetTab('next')}
|
||
className={cn(
|
||
'px-3 py-1 rounded-lg text-xs font-semibold transition-all flex items-center gap-1.5',
|
||
codeSnippetTab === 'next' ? 'bg-slate-800 text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-white'
|
||
)}
|
||
>
|
||
<Globe className="w-3.5 h-3.5" />
|
||
<span>Next.js / Node</span>
|
||
</button>
|
||
<button
|
||
onClick={() => setCodeSnippetTab('curl')}
|
||
className={cn(
|
||
'px-3 py-1 rounded-lg text-xs font-semibold transition-all flex items-center gap-1.5',
|
||
codeSnippetTab === 'curl' ? 'bg-slate-800 text-emerald-400 shadow-sm' : 'text-slate-400 hover:text-white'
|
||
)}
|
||
>
|
||
<Terminal className="w-3.5 h-3.5" />
|
||
<span>cURL</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Code display */}
|
||
<div className="relative group">
|
||
<pre className="bg-[#05070B] border border-slate-800/90 rounded-2xl p-5 text-xs text-emerald-300/90 font-mono overflow-x-auto leading-relaxed shadow-inner">
|
||
{codeSnippetTab === 'rn' && `// lib/config.ts
|
||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
|
||
const API_KEY = '${showApiKey ? app.api_key : '<<YOUR_API_KEY>>'}';
|
||
const ENDPOINT = '${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/v1/config';
|
||
|
||
export async function initRemoteConfig(env: 'development' | 'staging' | 'production' = 'production') {
|
||
try {
|
||
const res = await fetch(\`\${ENDPOINT}?env=\${env}\`, {
|
||
headers: { 'X-Api-Key': API_KEY },
|
||
});
|
||
if (!res.ok) throw new Error('Config fetch failed');
|
||
const remoteConfig = await res.json();
|
||
|
||
// Offline kullanım için depola
|
||
await AsyncStorage.setItem('@app_config', JSON.stringify(remoteConfig));
|
||
return remoteConfig;
|
||
} catch (error) {
|
||
const cached = await AsyncStorage.getItem('@app_config');
|
||
if (cached) return JSON.parse(cached);
|
||
throw error;
|
||
}
|
||
}`}
|
||
|
||
{codeSnippetTab === 'next' && `// lib/remoteConfig.ts
|
||
export async function getAppConfig(env = 'production') {
|
||
const res = await fetch(\`${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/v1/config?env=\${env}\`, {
|
||
headers: {
|
||
'X-Api-Key': '${showApiKey ? app.api_key : '<<YOUR_API_KEY>>'}',
|
||
},
|
||
next: { revalidate: 60 }, // 60 saniye ISR cache
|
||
});
|
||
|
||
if (!res.ok) {
|
||
throw new Error('Config load failed');
|
||
}
|
||
|
||
return res.json();
|
||
}`}
|
||
|
||
{codeSnippetTab === 'curl' && `curl -X GET "${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/api/v1/config?env=production" \\
|
||
-H "X-Api-Key: ${showApiKey ? app.api_key : '<<YOUR_API_KEY>>'}"`}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Danger Zone */}
|
||
<div className="glass-panel p-6 sm:p-7 rounded-3xl border-rose-500/20 bg-rose-950/5 space-y-6">
|
||
<div className="flex items-center gap-2 text-rose-400">
|
||
<AlertTriangle className="w-5 h-5" />
|
||
<h3 className="text-base font-bold">Kritik İşlemler (Danger Zone)</h3>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-rose-500/10">
|
||
<div>
|
||
<p className="text-sm font-semibold text-white">API Anahtarını Yenile (Rotate)</p>
|
||
<p className="text-slate-400 text-xs mt-0.5">
|
||
Eski anahtar anında geçersiz olur. Canlıdaki uygulamalar yeni anahtarı alana kadar config çekemez.
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={rotateApiKey}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 border border-amber-500/30 text-xs font-semibold transition-colors self-start sm:self-auto"
|
||
>
|
||
<RefreshCw className="w-3.5 h-3.5" />
|
||
<span>Anahtarı Yenile</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||
<div>
|
||
<p className="text-sm font-semibold text-white">Uygulamayı ve Tüm Verileri Sil</p>
|
||
<p className="text-slate-400 text-xs mt-0.5">
|
||
Bu uygulama, tüm ortamlardaki değişkenler ve audit logları kalıcı olarak silinecektir.
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={handleDeleteApp}
|
||
disabled={isDeletingApp}
|
||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-400 border border-rose-500/30 text-xs font-semibold transition-colors self-start sm:self-auto"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
<span>{isDeletingApp ? 'Siliniyor...' : 'Uygulamayı Sil'}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ─── TAB 3: AUDIT LOGS ─── */}
|
||
{activeTab === 'audit' && (
|
||
<div className="glass-panel rounded-3xl overflow-hidden shadow-xl animate-fade-in">
|
||
<div className="px-6 py-4 border-b border-slate-800/80 flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-base font-bold text-white">İşlem & Güvenlik Denetim Kayıtları (Audit Log)</h2>
|
||
<p className="text-slate-400 text-xs mt-0.5">Son 30 konfigürasyon ve anahtar işlemi</p>
|
||
</div>
|
||
<span className="text-xs px-2.5 py-1 rounded-full bg-slate-900 border border-slate-800 text-slate-400 font-mono">
|
||
{auditLogs.length} Kayıt
|
||
</span>
|
||
</div>
|
||
|
||
{auditLogs.length === 0 ? (
|
||
<div className="text-center py-16 text-slate-500 text-sm">
|
||
Henüz kayıtlı bir işlem bulunmuyor
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-slate-800/50">
|
||
{auditLogs.map(log => {
|
||
const actionStyles = {
|
||
create: { badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', label: 'Eklendi' },
|
||
update: { badge: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20', label: 'Güncellendi' },
|
||
delete: { badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20', label: 'Silindi' },
|
||
rotate_key: { badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20', label: 'Key Yenilendi' },
|
||
create_app: { badge: 'bg-purple-500/10 text-purple-400 border-purple-500/20', label: 'App Oluşturuldu' },
|
||
delete_app: { badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20', label: 'App Silindi' },
|
||
}[log.action] || { badge: 'bg-slate-500/10 text-slate-400 border-slate-500/20', label: log.action }
|
||
|
||
return (
|
||
<div key={log.id} className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-3 hover:bg-slate-900/30 transition-colors">
|
||
<div className="flex items-start sm:items-center gap-3">
|
||
<span className={cn('text-xs px-2.5 py-1 rounded-lg border font-semibold', actionStyles.badge)}>
|
||
{actionStyles.label}
|
||
</span>
|
||
|
||
<div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-mono text-sm text-white font-bold">
|
||
{log.key || log.action}
|
||
</span>
|
||
{log.environment && (
|
||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded bg-slate-800 text-slate-300 uppercase tracking-wide">
|
||
{log.environment}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{log.old_value && (
|
||
<p className="text-slate-500 text-xs font-mono mt-1 line-clamp-1">
|
||
Eski değer: <span className="line-through">{maskSecret(log.old_value)}</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-4 text-xs text-slate-500 self-end sm:self-auto">
|
||
<span className="px-2 py-0.5 rounded bg-slate-900 border border-slate-800 text-slate-400">
|
||
{log.actor}
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<Clock className="w-3.5 h-3.5" />
|
||
<span>{new Date(log.created_at).toLocaleString('tr-TR')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ─── ADD CONFIG MODAL ─── */}
|
||
{showAddModal && (
|
||
<AddConfigModal
|
||
environmentId={currentEnv?.id ?? ''}
|
||
environment={activeEnv}
|
||
appId={app.id}
|
||
onClose={() => setShowAddModal(false)}
|
||
onSaved={() => {
|
||
setShowAddModal(false)
|
||
router.refresh()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* ─── EDIT APP MODAL ─── */}
|
||
{showEditModal && (
|
||
<EditAppModal
|
||
app={app}
|
||
onClose={() => setShowEditModal(false)}
|
||
onSaved={() => {
|
||
setShowEditModal(false)
|
||
router.refresh()
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── ADD CONFIG MODAL ────────────────────────────────────────────────
|
||
function AddConfigModal({
|
||
environmentId, environment, appId, onClose, onSaved
|
||
}: {
|
||
environmentId: string
|
||
environment: string
|
||
appId: string
|
||
onClose: () => void
|
||
onSaved: () => void
|
||
}) {
|
||
const [key, setKey] = useState('')
|
||
const [value, setValue] = useState('')
|
||
const [type, setType] = useState<ConfigType>('text')
|
||
const [description, setDescription] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
async function handleSave(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
if (!key || !value) return
|
||
setLoading(true)
|
||
setError('')
|
||
|
||
const res = await fetch(`/api/apps/${appId}/config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ environment_id: environmentId, key, value, type, description, environment }),
|
||
})
|
||
|
||
const data = await res.json()
|
||
setLoading(false)
|
||
if (!res.ok) {
|
||
setError(data.error || 'Kaydedilirken hata oluştu')
|
||
return
|
||
}
|
||
onSaved()
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-md animate-fade-in">
|
||
<div className="glass-panel w-full max-w-lg rounded-3xl p-6 sm:p-8 shadow-2xl border border-slate-700/80 relative overflow-hidden">
|
||
{/* 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" />
|
||
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||
<Plus className="w-5 h-5 text-emerald-400" />
|
||
<span>Yeni Config / Secret Ekle</span>
|
||
</h3>
|
||
<p className="text-slate-400 text-xs mt-0.5">
|
||
Hedef Ortam: <span className="text-emerald-400 font-semibold uppercase">{environment}</span>
|
||
</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>
|
||
|
||
<form onSubmit={handleSave} className="space-y-4">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
|
||
Anahtar (Key) <span className="text-rose-400">*</span>
|
||
</label>
|
||
<input
|
||
value={key}
|
||
onChange={e => setKey(e.target.value.toUpperCase().replace(/\s/g, '_'))}
|
||
placeholder="SUPABASE_ANON_KEY"
|
||
required
|
||
autoFocus
|
||
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm font-mono focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
|
||
Veri Tipi <span className="text-rose-400">*</span>
|
||
</label>
|
||
<select
|
||
value={type}
|
||
onChange={e => setType(e.target.value as ConfigType)}
|
||
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm focus:outline-none"
|
||
>
|
||
<option value="text">Text (Düz Metin)</option>
|
||
<option value="secret">Secret (Gizli/Şifreli)</option>
|
||
<option value="url">URL</option>
|
||
<option value="json">JSON</option>
|
||
<option value="boolean">Boolean (True/False)</option>
|
||
<option value="number">Number (Sayısal)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
|
||
Değer (Value) <span className="text-rose-400">*</span>
|
||
</label>
|
||
{type === 'json' ? (
|
||
<textarea
|
||
value={value}
|
||
onChange={e => setValue(e.target.value)}
|
||
placeholder='{"features": {"darkMode": true}}'
|
||
rows={4}
|
||
required
|
||
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm font-mono focus:outline-none resize-none"
|
||
/>
|
||
) : (
|
||
<input
|
||
type={type === 'secret' ? 'password' : 'text'}
|
||
value={value}
|
||
onChange={e => setValue(e.target.value)}
|
||
placeholder={
|
||
type === 'url' ? 'https://api.myapp.com' :
|
||
type === 'secret' ? 'eyJhbGciOiJIUzI1NiIsIn...' :
|
||
type === 'boolean' ? 'true' :
|
||
type === 'number' ? '5000' : 'Değer girin...'
|
||
}
|
||
required
|
||
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm font-mono focus:outline-none"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
|
||
Açıklama <span className="text-slate-500 font-normal lowercase">(isteğe bağlı)</span>
|
||
</label>
|
||
<input
|
||
value={description}
|
||
onChange={e => setDescription(e.target.value)}
|
||
placeholder="Örn: Supabase istemci kimlik doğrulaması"
|
||
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="bg-rose-500/10 border border-rose-500/30 rounded-xl p-3 text-rose-300 text-xs">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3 pt-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold text-xs py-2.5 rounded-xl transition-colors"
|
||
>
|
||
İptal
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading || !key || !value}
|
||
className="flex-1 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 disabled:opacity-50 text-slate-950 font-bold text-xs py-2.5 rounded-xl transition-all shadow-lg shadow-emerald-500/25 flex items-center justify-center gap-1.5"
|
||
>
|
||
{loading ? 'Kaydediliyor...' : 'Değişkeni Kaydet'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── CONFIG ROW COMPONENT ────────────────────────────────────────────
|
||
function ConfigRow({
|
||
entry, appId, environment, revealed, onToggleReveal, onDeleted, onSaved
|
||
}: {
|
||
entry: ConfigEntry
|
||
appId: string
|
||
environment: string
|
||
revealed: boolean
|
||
onToggleReveal: () => void
|
||
onDeleted: () => void
|
||
onSaved: () => void
|
||
}) {
|
||
const [editing, setEditing] = useState(false)
|
||
const [editValue, setEditValue] = useState(entry.value)
|
||
const [loading, setLoading] = useState(false)
|
||
const [copied, setCopied] = useState(false)
|
||
const isSecret = entry.type === 'secret'
|
||
|
||
async function handleDelete() {
|
||
if (!confirm(`"${entry.key}" değişkenini silmek istediğinize emin misiniz?`)) return
|
||
await fetch(`/api/apps/${appId}/config/${entry.id}?environment=${environment}`, { method: 'DELETE' })
|
||
onDeleted()
|
||
}
|
||
|
||
async function handleSave() {
|
||
setLoading(true)
|
||
await fetch(`/api/apps/${appId}/config/${entry.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ value: editValue, environment }),
|
||
})
|
||
setLoading(false)
|
||
setEditing(false)
|
||
onSaved()
|
||
}
|
||
|
||
const displayValue = isSecret && !revealed ? maskSecret(entry.value) : entry.value
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-3 sm:gap-4 px-6 py-4 border-b border-slate-800/50 last:border-0 hover:bg-slate-800/25 transition-colors items-center group">
|
||
{/* Key & Description */}
|
||
<div className="sm:col-span-4 min-w-0">
|
||
<p className="font-mono text-xs sm:text-sm text-white font-semibold truncate select-all">
|
||
{entry.key}
|
||
</p>
|
||
{entry.description && (
|
||
<p className="text-slate-500 text-xs mt-0.5 truncate leading-tight">
|
||
{entry.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Type Badge */}
|
||
<div className="sm:col-span-2 flex items-center">
|
||
<span className={cn('text-[11px] px-2 py-0.5 rounded-md border font-medium uppercase tracking-wider', typeColor(entry.type))}>
|
||
{entry.type}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Value */}
|
||
<div className="sm:col-span-4 min-w-0">
|
||
{editing ? (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
value={editValue}
|
||
onChange={e => setEditValue(e.target.value)}
|
||
autoFocus
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') handleSave()
|
||
if (e.key === 'Escape') setEditing(false)
|
||
}}
|
||
className="w-full glass-input rounded-lg px-2.5 py-1.5 text-white text-xs font-mono focus:outline-none"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<p className={cn(
|
||
'font-mono text-xs sm:text-sm truncate select-all',
|
||
isSecret && !revealed ? 'text-slate-500 font-sans tracking-widest' : 'text-emerald-300/90'
|
||
)}>
|
||
{displayValue}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Action buttons */}
|
||
<div className="sm:col-span-2 flex items-center justify-end gap-1 pt-2 sm:pt-0 border-t sm:border-t-0 border-slate-800/60">
|
||
{isSecret && (
|
||
<button
|
||
onClick={onToggleReveal}
|
||
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||
title={revealed ? 'Gizle' : 'Değeri Göster'}
|
||
>
|
||
{revealed ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(entry.value)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 1500)
|
||
}}
|
||
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||
title="Değeri Kopyala"
|
||
>
|
||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||
</button>
|
||
|
||
{editing ? (
|
||
<>
|
||
<button
|
||
onClick={() => setEditing(false)}
|
||
className="p-1.5 text-slate-400 hover:text-rose-400 hover:bg-slate-800 rounded-lg transition-colors"
|
||
title="İptal (Esc)"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={loading}
|
||
className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-slate-800 rounded-lg transition-colors"
|
||
title="Kaydet (Enter)"
|
||
>
|
||
<Check className="w-3.5 h-3.5 stroke-[2.5]" />
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
onClick={() => setEditing(true)}
|
||
className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors"
|
||
title="Düzenle"
|
||
>
|
||
<Edit2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
|
||
<button
|
||
onClick={handleDelete}
|
||
className="p-1.5 text-slate-400 hover:text-rose-400 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||
title="Sil"
|
||
>
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── EDIT APP MODAL ──────────────────────────────────────────────────
|
||
function EditAppModal({
|
||
app, onClose, onSaved
|
||
}: {
|
||
app: App
|
||
onClose: () => void
|
||
onSaved: () => void
|
||
}) {
|
||
const [name, setName] = useState(app.name)
|
||
const [description, setDescription] = useState(app.description || '')
|
||
const [iconUrl, setIconUrl] = useState(app.icon_url || '')
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
async function handleSave(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
if (!name) return
|
||
setLoading(true)
|
||
setError('')
|
||
|
||
try {
|
||
const res = await fetch(`/api/apps/${app.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name,
|
||
description: description || null,
|
||
icon_url: iconUrl || null,
|
||
}),
|
||
})
|
||
|
||
const data = await res.json()
|
||
setLoading(false)
|
||
if (!res.ok) {
|
||
setError(data.error || 'Uygulama güncellenirken hata oluştu')
|
||
return
|
||
}
|
||
onSaved()
|
||
} catch {
|
||
setLoading(false)
|
||
setError('Ağ hatası oluştu, lütfen tekrar deneyin.')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-md animate-fade-in">
|
||
<div className="glass-panel w-full max-w-lg rounded-3xl p-6 sm:p-8 shadow-2xl border border-slate-700/80 relative overflow-hidden max-h-[90vh] overflow-y-auto">
|
||
{/* 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" />
|
||
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||
<Settings className="w-5 h-5 text-emerald-400" />
|
||
<span>Uygulama & İkon Ayarları</span>
|
||
</h3>
|
||
<p className="text-slate-400 text-xs mt-0.5">
|
||
İkonu, ismi ve açıklamayı güncelleyin
|
||
</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>
|
||
|
||
<form onSubmit={handleSave} className="space-y-5">
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
|
||
Uygulama Adı <span className="text-rose-400">*</span>
|
||
</label>
|
||
<input
|
||
value={name}
|
||
onChange={e => setName(e.target.value)}
|
||
required
|
||
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm font-medium focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1.5">
|
||
Açıklama
|
||
</label>
|
||
<textarea
|
||
value={description}
|
||
onChange={e => setDescription(e.target.value)}
|
||
rows={2}
|
||
className="w-full glass-input rounded-xl px-3.5 py-2.5 text-white text-xs sm:text-sm resize-none focus:outline-none"
|
||
/>
|
||
</div>
|
||
|
||
{/* Icon Picker */}
|
||
<IconPicker
|
||
value={iconUrl}
|
||
onChange={setIconUrl}
|
||
appName={name}
|
||
/>
|
||
|
||
{error && (
|
||
<div className="bg-rose-500/10 border border-rose-500/30 rounded-xl p-3 text-rose-300 text-xs">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3 pt-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-300 font-semibold text-xs py-2.5 rounded-xl transition-colors"
|
||
>
|
||
İptal
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={loading || !name}
|
||
className="flex-1 bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 disabled:opacity-50 text-slate-950 font-bold text-xs py-2.5 rounded-xl transition-all shadow-lg shadow-emerald-500/25 flex items-center justify-center gap-1.5"
|
||
>
|
||
{loading ? 'Kaydediliyor...' : 'Değişiklikleri Kaydet'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|